From 46f5ceaf25d94b691392753b58fb59b2d131d077 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 30 May 2026 01:36:07 +0300 Subject: [PATCH 01/38] Add test coverage tasks --- app/build.gradle.kts | 28 +++- app/jacoco.gradle.kts | 230 +++++++++++++++++++++++++++++++ gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 94 +++++++++++++ 4 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 app/jacoco.gradle.kts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b01f5f30e..152ee16b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.detekt) alias(libs.plugins.hilt) + jacoco alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) @@ -78,6 +79,14 @@ android { res.srcDir("../res") } + sourceSets.getByName("test") { + kotlin.srcDir("src/sharedTest/kotlin") + } + + sourceSets.getByName("androidTest") { + kotlin.srcDir("src/sharedTest/kotlin") + } + val keystorePropertiesFile = rootProject.file("keystore.properties") val useKeystoreProperties = keystorePropertiesFile.canRead() val keystoreProperties = Properties() @@ -104,7 +113,7 @@ android { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "../proguard.flags", - "../proguard-release.flags" + "../proguard-release.flags", ) if (useKeystoreProperties) { @@ -115,6 +124,8 @@ android { getByName("debug") { applicationIdSuffix = ".debug" resValue("string", "app_name", "Messaging d") + enableUnitTestCoverage = true + enableAndroidTestCoverage = true } create("perf") { @@ -126,6 +137,19 @@ android { } } + testCoverage { + jacocoVersion = libs.versions.jacoco.get() + } + + testOptions { + unitTests.all { + it.extensions.configure(JacocoTaskExtension::class.java) { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } + } + } + lint { abortOnError = false } @@ -212,3 +236,5 @@ dependencies { androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.turbine) } + +apply(from = "jacoco.gradle.kts") diff --git a/app/jacoco.gradle.kts b/app/jacoco.gradle.kts new file mode 100644 index 000000000..6f7ebd31b --- /dev/null +++ b/app/jacoco.gradle.kts @@ -0,0 +1,230 @@ +import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification +import org.gradle.testing.jacoco.tasks.JacocoReport + +val unitTestIncludedPackages: List = listOf( + "com/android/messaging/data/**", + "com/android/messaging/domain/**", + "com/android/messaging/sms/**", + "com/android/messaging/ui/appsettings/**", + "com/android/messaging/ui/contact/**", + "com/android/messaging/ui/conversation/**", + "com/android/messaging/ui/core/**", + "com/android/messaging/util/core/**", + "com/android/messaging/util/db/**", +) + +val androidTestIncludedPackages: List = listOf( + "com/android/messaging/data/appsettings/**", + "com/android/messaging/domain/subscriptionsettings/**", + "com/android/messaging/ui/appsettings/**", + "com/android/messaging/ui/contact/**", + "com/android/messaging/ui/conversation/**", + "com/android/messaging/ui/core/**", +) + +val coverageExcludedClasses: List = listOf( + "**/_Factory*", + "**/Hilt_*", + "**/_HiltModules*", + "**/_GeneratedInjector*", + "**/_MembersInjector*", + "**/Dagger*", + "**/*ComposableSingletons*", + $$"**/*$serializer", + "**/model/**", + $$$"**/*$$inlined$*", + $$"**/*$invokeSuspend$*", + $$"**/*$DefaultImpls*", +) + (0..9).flatMap { i -> + listOf( + $$"**/*$$${i}.class", + $$"**/*$?$${i}.class", + $$"**/*$??$${i}.class", + ) +} + +val unitTestExcludedClasses: List = listOf( + "**/*Exception.class", + "**/*Exception$*.class", + "**/*Activity.class", + "**/*Activity$*.class", + "**/ExceptionsKt.class", + "**/*Fragment.class", + "**/*Fragment$*.class", + "**/*Receiver.class", + "**/*Receiver$*.class", + "**/*Service.class", + "**/*Service$*.class", + "**/data/appsettings/repository/AppSettingsRepositoryImpl.class", + "**/domain/subscriptionsettings/usecase/SetSubscriptionPhoneNumberImpl.class", + "**/ui/appsettings/general/mapper/AppSettingsUiStateMapperImpl.class", + "**/ui/appsettings/subscription/mapper/SubscriptionSettingsUiStateMapperImpl.class", + "**/ui/conversation/ConversationTestTagsKt.class", + "**/ui/conversation/composer/ui/*.class", + "**/ui/conversation/mediapicker/camera/ConversationCameraControllerImpl*.class", + "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureRecordingStopVisualState.class", + "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterPhase.class", + "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterSurfaceVisualState.class", + "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterVisualState.class", + "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureVideoCenterDotVisualState.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundSelection.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundState.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCardContentState.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCardState.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerCoordinator.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerLayout.class", + "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.class", + "**/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentColors.class", + "**/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.class", + "**/ui/conversation/metadata/ui/ConversationTopAppBarOverflowVisibility.class", + "**/ui/conversation/metadata/ui/ConversationTopAppBarPresentation.class", +) + +fun getComposableUnitExcludes(): List { + val excludes = mutableListOf() + val uiSrcDir = file("../src/com/android/messaging/ui") + if (uiSrcDir.exists()) { + uiSrcDir.walkTopDown().forEach { file -> + if (file.isFile && file.extension == "kt") { + val content = file.readText() + if (content.contains("@Composable")) { + val baseName = file.nameWithoutExtension + excludes.add("**/ui/**/${baseName}Kt*.class") + } + } + } + } + return excludes +} + +fun getJavaExclusions(): List { + val javaExcludes = mutableListOf() + val javacDir = file("build/intermediates/javac/debug/compileDebugJavaWithJavac/classes") + if (javacDir.exists()) { + javacDir.walkTopDown().forEach { file -> + if (file.isFile && file.extension == "class") { + val relativePath = file.relativeTo(javacDir).path + javaExcludes.add(relativePath) + } + } + } + return javaExcludes +} + +fun getKotlinClassTree(isUnitTestTrack: Boolean): FileTree { + val includedPackages = + if (isUnitTestTrack) unitTestIncludedPackages else androidTestIncludedPackages + val baseDir = if (isUnitTestTrack) { + "build/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes" + } else { + "build/intermediates/classes/debug/transformDebugClassesWithAsm/dirs" + } + return fileTree(baseDir) { + include(includedPackages) + exclude(coverageExcludedClasses) + if (isUnitTestTrack) { + exclude(getComposableUnitExcludes()) + exclude(unitTestExcludedClasses) + } else { + exclude(getJavaExclusions()) + } + } +} + +tasks.register("jacocoUnitTestReport") { + dependsOn("testDebugUnitTest") + group = "Reporting" + description = "Generate Jacoco coverage report for the unit tests." + + reports { + xml.required.set(true) + html.required.set(true) + } + + classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = true)) + sourceDirectories.setFrom(files("../src")) + executionData.setFrom( + fileTree("build") { + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + }, + ) +} + +tasks.register("jacocoUnitTestVerification") { + dependsOn("jacocoUnitTestReport") + group = "Verification" + description = "Verify Jacoco coverage for unit tests." + + classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = true)) + sourceDirectories.setFrom(files("../src")) + executionData.setFrom( + fileTree("build") { + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + }, + ) + + violationRules { + rule { + element = "BUNDLE" + limit { + counter = "INSTRUCTION" + value = "COVEREDRATIO" + val minCoverage = project + .findProperty("unitTestMinCoverage") + ?.toString() + ?.toDoubleOrNull() + ?: 0.0 + minimum = (minCoverage / 100.0).toBigDecimal() + } + } + } +} + +tasks.register("jacocoAndroidTestReport") { + group = "Reporting" + description = "Generate Jacoco coverage report for instrumented (Android) tests." + + reports { + xml.required.set(true) + html.required.set(true) + } + + classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = false)) + sourceDirectories.setFrom(files("../src")) + executionData.setFrom( + fileTree("build") { + include("outputs/code_coverage/debugAndroidTest/connected/**/*.ec") + }, + ) +} + +tasks.register("jacocoAndroidTestVerification") { + dependsOn("jacocoAndroidTestReport") + group = "Verification" + description = "Verify Jacoco coverage for instrumented (Android) tests." + + classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = false)) + sourceDirectories.setFrom(files("../src")) + executionData.setFrom( + fileTree("build") { + include("outputs/code_coverage/debugAndroidTest/connected/**/*.ec") + }, + ) + + violationRules { + rule { + element = "BUNDLE" + limit { + counter = "INSTRUCTION" + value = "COVEREDRATIO" + val minCoverage = project + .findProperty("androidTestMinCoverage") + ?.toString() + ?.toDoubleOrNull() + ?: 0.0 + + minimum = (minCoverage / 100.0).toBigDecimal() + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c351889b..3f4b9d26f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ agp = "9.2.1" detekt = "2.0.0-alpha.3" hilt = "2.59.2" +jacoco = "0.8.14" kotlin = "2.3.21" kotlinx-serialization = "1.11.0" ksp = "2.3.6" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 83d985a3d..f0456cea3 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -8683,5 +8683,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d1c6977223ea540203e53bbd5c054a1294661b40 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:32:45 +0300 Subject: [PATCH 02/38] Add shared unit test utilities --- .../messaging/testutil/MainDispatcherRule.kt | 0 .../messaging/testutil/TestConstants.kt | 7 ++ .../RecipientSelectionTestTags.kt | 6 ++ ...RecipientSelectionComponentTestFixtures.kt | 3 + .../testutil/ConversationMetadataFixtures.kt | 24 +++++ .../testutil/ParticipantCursorFixtures.kt | 88 +++++++++++++++++++ app/src/test/resources/robolectric.properties | 1 + 7 files changed, 129 insertions(+) rename app/src/{test/java => sharedTest/kotlin}/com/android/messaging/testutil/MainDispatcherRule.kt (100%) create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/testutil/TestConstants.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionTestTags.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionComponentTestFixtures.kt create mode 100644 app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt create mode 100644 app/src/test/kotlin/com/android/messaging/testutil/ParticipantCursorFixtures.kt create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/src/test/java/com/android/messaging/testutil/MainDispatcherRule.kt b/app/src/sharedTest/kotlin/com/android/messaging/testutil/MainDispatcherRule.kt similarity index 100% rename from app/src/test/java/com/android/messaging/testutil/MainDispatcherRule.kt rename to app/src/sharedTest/kotlin/com/android/messaging/testutil/MainDispatcherRule.kt diff --git a/app/src/sharedTest/kotlin/com/android/messaging/testutil/TestConstants.kt b/app/src/sharedTest/kotlin/com/android/messaging/testutil/TestConstants.kt new file mode 100644 index 000000000..e619fa50e --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/testutil/TestConstants.kt @@ -0,0 +1,7 @@ +package com.android.messaging.testutil + +internal const val TEST_CALL_ACTION_PHONE_NUMBER = "+15551234567" +internal const val TEST_CONTACT_URI = "content://contacts/lookup/1" +internal const val TEST_CONVERSATION_ID = "conversation-1" +internal const val TEST_SELF_SUB_ID = 7 +internal const val TEST_WAIT_TIMEOUT_MILLIS = 5_000L diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionTestTags.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionTestTags.kt new file mode 100644 index 000000000..74f3e7da9 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionTestTags.kt @@ -0,0 +1,6 @@ +package com.android.messaging.ui.conversation.recipientpicker + +internal const val RECIPIENT_SELECTION_PRIMARY_ACTION_TEST_TAG = + "recipient_selection_primary_action" +internal const val RECIPIENT_SELECTION_TRAILING_INDICATOR_TEST_TAG = + "recipient_selection_trailing_indicator" diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionComponentTestFixtures.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionComponentTestFixtures.kt new file mode 100644 index 000000000..0cd6e3669 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionComponentTestFixtures.kt @@ -0,0 +1,3 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +internal const val RECIPIENT_SELECTION_PLACEHOLDER_TEXT = "Name, phone or email" diff --git a/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt b/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt new file mode 100644 index 000000000..0b8468783 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt @@ -0,0 +1,24 @@ +package com.android.messaging.testutil + +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata + +internal fun createConversationMetadata( + isGroupConversation: Boolean = false, + includeEmailAddress: Boolean = false, +): ConversationMetadata { + return ConversationMetadata( + conversationName = "Conversation", + selfParticipantId = "self-1", + isGroupConversation = isGroupConversation, + includeEmailAddress = includeEmailAddress, + participantCount = 1, + otherParticipantDisplayDestination = "Alice", + otherParticipantNormalizedDestination = "123", + otherParticipantContactLookupKey = null, + otherParticipantPhotoUri = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + sortTimestamp = 0L, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/testutil/ParticipantCursorFixtures.kt b/app/src/test/kotlin/com/android/messaging/testutil/ParticipantCursorFixtures.kt new file mode 100644 index 000000000..67cdb941d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/testutil/ParticipantCursorFixtures.kt @@ -0,0 +1,88 @@ +package com.android.messaging.testutil + +import android.database.MatrixCursor +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.data.ParticipantData + +internal fun createParticipantsCursor(vararg rows: TestParticipantRow): MatrixCursor { + val cursor = MatrixCursor(ParticipantData.ParticipantsQuery.PROJECTION) + rows.forEach { row -> + cursor.addRow( + ParticipantData.ParticipantsQuery.PROJECTION.map { columnName -> + row.toColumnValues()[columnName] + }.toTypedArray(), + ) + } + return cursor +} + +internal fun participantRow( + participantId: String, + subId: Int = ParticipantData.OTHER_THAN_SELF_SUB_ID, + slotId: Int = 0, + subscriptionName: String? = "", + displayDestination: String = "", + normalizedDestination: String = displayDestination, + sendDestination: String = normalizedDestination, + profilePhotoUri: String? = "", + lookupKey: String = "", + contactId: Long = 0L, + fullName: String = "", + firstName: String = "", + subscriptionColor: Int = 0, + contactDestination: String = normalizedDestination, +): TestParticipantRow { + return TestParticipantRow( + participantId = participantId, + subId = subId, + slotId = slotId, + subscriptionName = subscriptionName, + displayDestination = displayDestination, + normalizedDestination = normalizedDestination, + sendDestination = sendDestination, + profilePhotoUri = profilePhotoUri, + lookupKey = lookupKey, + contactId = contactId, + fullName = fullName, + firstName = firstName, + subscriptionColor = subscriptionColor, + contactDestination = contactDestination, + ) +} + +internal data class TestParticipantRow( + val participantId: String, + val subId: Int = ParticipantData.OTHER_THAN_SELF_SUB_ID, + val slotId: Int = 0, + val subscriptionName: String? = "", + val displayDestination: String = "", + val normalizedDestination: String = "", + val sendDestination: String = normalizedDestination, + val profilePhotoUri: String? = "", + val lookupKey: String = "", + val contactId: Long = 0L, + val fullName: String = "", + val firstName: String = "", + val subscriptionColor: Int = 0, + val contactDestination: String = normalizedDestination, +) { + fun toColumnValues(): Map { + return mapOf( + ParticipantColumns._ID to participantId, + ParticipantColumns.SUB_ID to subId, + ParticipantColumns.SIM_SLOT_ID to slotId, + ParticipantColumns.NORMALIZED_DESTINATION to normalizedDestination, + ParticipantColumns.SEND_DESTINATION to sendDestination, + ParticipantColumns.DISPLAY_DESTINATION to displayDestination, + ParticipantColumns.FULL_NAME to fullName, + ParticipantColumns.FIRST_NAME to firstName, + ParticipantColumns.PROFILE_PHOTO_URI to profilePhotoUri, + ParticipantColumns.CONTACT_ID to contactId, + ParticipantColumns.LOOKUP_KEY to lookupKey, + ParticipantColumns.BLOCKED to 0, + ParticipantColumns.SUBSCRIPTION_COLOR to subscriptionColor, + ParticipantColumns.SUBSCRIPTION_NAME to subscriptionName, + ParticipantColumns.CONTACT_DESTINATION to contactDestination, + ) + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 000000000..3f67ea5ac --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 From 13b0664abb91c52af02294f6285def0593f1f043 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:34:05 +0300 Subject: [PATCH 03/38] Add core domain unit tests --- .../repository/ContactsRepositoryImplTest.kt | 600 +++++++++--------- ...IsReadContactsPermissionGrantedImplTest.kt | 83 +++ ...kConversationActionRequirementsImplTest.kt | 106 ++++ .../CreateDefaultSmsRoleRequestImplTest.kt | 45 ++ .../CreateForwardedMessageImplTest.kt | 349 ++++++++++ ...AddMoreConversationParticipantsImplTest.kt | 51 ++ .../ResolveConversationIdImplTest.kt | 203 ++++++ .../IsEmergencyPhoneNumberImplTest.kt | 68 ++ .../messaging/sms/MmsSubjectSanitizerTest.kt | 55 ++ .../extension/KotlinFlowExtensionsTest.kt | 45 ++ .../messaging/util/db/ReversedCursorTest.kt | 105 +++ 11 files changed, 1413 insertions(+), 297 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGrantedImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirementsImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequestImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/forward/createforwardedmessage/CreateForwardedMessageImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipantsImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationIdImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumberImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/sms/MmsSubjectSanitizerTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinFlowExtensionsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/util/db/ReversedCursorTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt index 9ed4e2497..71626bee9 100644 --- a/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt @@ -9,6 +9,7 @@ import com.android.messaging.data.contact.formatter.ContactDestinationFormatterI import com.android.messaging.data.contact.model.Contact import com.android.messaging.data.contact.model.ContactDestination import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.util.PhoneUtils import io.mockk.every import io.mockk.mockk @@ -16,18 +17,21 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -internal class ContactsRepositoryImplTest { +class ContactsRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() private val contentResolver = mockk() private val phoneUtilsInstance = mockk(relaxed = true) @@ -64,355 +68,357 @@ internal class ContactsRepositoryImplTest { } @Test - fun multiNumberContactReturnsAllDestinationsWithSuperPrimaryFirst() = runTest { - stubFilterPhoneCursor( - query = "Multi", - rows = listOf( - phoneRow( - contactId = 1L, - sortKey = "Multi Person", - number = "+15550001", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - isPrimary = false, - isSuperPrimary = false, - ), - ), - ) - stubFilterEmailCursor(query = "Multi", rows = emptyList()) - stubExpansionPhoneCursor( - rows = listOf( - phoneRow( - contactId = 1L, - sortKey = "Multi Person", - number = "+15550001", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + fun multiNumberContactReturnsAllDestinationsWithSuperPrimaryFirst() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubFilterPhoneCursor( + query = "Multi", + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550001", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + isPrimary = false, + isSuperPrimary = false, + ), ), - phoneRow( - contactId = 1L, - sortKey = "Multi Person", - number = "+15550002", - type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, - isSuperPrimary = true, - ), - phoneRow( - contactId = 1L, - sortKey = "Multi Person", - number = "+15550003", - type = ContactsContract.CommonDataKinds.Phone.TYPE_WORK, - isPrimary = true, + ) + stubFilterEmailCursor(query = "Multi", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550001", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550002", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + isSuperPrimary = true, + ), + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550003", + type = ContactsContract.CommonDataKinds.Phone.TYPE_WORK, + isPrimary = true, + ), ), - ), - ) - stubExpansionEmailCursor(rows = emptyList()) + ) + stubExpansionEmailCursor(rows = emptyList()) - val repo = createRepository() - val page = repo.searchContacts(query = "Multi", offset = 0).first() + val repo = createRepository() + val page = repo.searchContacts(query = "Multi", offset = 0).first() - val contact = page.contacts.single() - Assert.assertEquals(1L, contact.id) - Assert.assertEquals(3, contact.destinations.size) - Assert.assertEquals( - listOf("+15550002", "+15550003", "+15550001"), - contact.destinations.map { it.value }, - ) - Assert.assertEquals(ContactDestination.Kind.PHONE, contact.destinations[0].kind) - Assert.assertTrue(contact.destinations[0].isSuperPrimary) - Assert.assertNull(page.nextOffset) + val contact = page.contacts.single() + Assert.assertEquals(1L, contact.id) + Assert.assertEquals(3, contact.destinations.size) + Assert.assertEquals( + listOf("+15550002", "+15550003", "+15550001"), + contact.destinations.map { it.value }, + ) + Assert.assertEquals(ContactDestination.Kind.PHONE, contact.destinations[0].kind) + Assert.assertTrue(contact.destinations[0].isSuperPrimary) + Assert.assertNull(page.nextOffset) + } } @Test - fun nameAndNumberMatchesCollapseToSingleContactExpandedOnce() = runTest { - stubFilterPhoneCursor( - query = "Bob", - rows = listOf( - phoneRow( - contactId = 7L, - sortKey = "Bob", - number = "+17777777", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + fun nameAndNumberMatchesCollapseToSingleContactExpandedOnce() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubFilterPhoneCursor( + query = "Bob", + rows = listOf( + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777777", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777778", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ), ), - phoneRow( - contactId = 7L, - sortKey = "Bob", - number = "+17777778", - type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, - ), - ), - ) - stubFilterEmailCursor(query = "Bob", rows = emptyList()) - stubExpansionPhoneCursor( - rows = listOf( - phoneRow( - contactId = 7L, - sortKey = "Bob", - number = "+17777777", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - ), - phoneRow( - contactId = 7L, - sortKey = "Bob", - number = "+17777778", - type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ) + stubFilterEmailCursor(query = "Bob", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777777", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777778", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ), ), - ), - ) - stubExpansionEmailCursor(rows = emptyList()) + ) + stubExpansionEmailCursor(rows = emptyList()) - val repo = createRepository() - val page = repo.searchContacts(query = "Bob", offset = 0).first() + val repo = createRepository() + val page = repo.searchContacts(query = "Bob", offset = 0).first() - val contact = page.contacts.single() - Assert.assertEquals(7L, contact.id) - Assert.assertEquals( - listOf("+17777777", "+17777778"), - contact.destinations.map { it.value }, - ) + val contact = page.contacts.single() + Assert.assertEquals(7L, contact.id) + Assert.assertEquals( + listOf("+17777777", "+17777778"), + contact.destinations.map { it.value }, + ) + } } @Test - fun twoContactsSharingNumberBothKeepIt() = runTest { - stubFilterPhoneCursor( - query = "Same", - rows = listOf( - phoneRow( - contactId = 1L, - sortKey = "Alpha", - number = "+15551111", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + fun twoContactsSharingNumberBothKeepIt() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubFilterPhoneCursor( + query = "Same", + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Alpha", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 2L, + sortKey = "Beta", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), ), - phoneRow( - contactId = 2L, - sortKey = "Beta", - number = "+15551111", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - ), - ), - ) - stubFilterEmailCursor(query = "Same", rows = emptyList()) - stubExpansionPhoneCursor( - rows = listOf( - phoneRow( - contactId = 1L, - sortKey = "Alpha", - number = "+15551111", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - ), - phoneRow( - contactId = 2L, - sortKey = "Beta", - number = "+15551111", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + stubFilterEmailCursor(query = "Same", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Alpha", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 2L, + sortKey = "Beta", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), ), - ), - ) - stubExpansionEmailCursor(rows = emptyList()) + ) + stubExpansionEmailCursor(rows = emptyList()) - val repo = createRepository() - val page = repo.searchContacts(query = "Same", offset = 0).first() + val repo = createRepository() + val page = repo.searchContacts(query = "Same", offset = 0).first() - Assert.assertEquals(listOf(1L, 2L), page.contacts.map(Contact::id)) - page.contacts.forEach { contact -> - Assert.assertEquals("+15551111", contact.destinations.single().value) + Assert.assertEquals(listOf(1L, 2L), page.contacts.map(Contact::id)) + page.contacts.forEach { contact -> + Assert.assertEquals("+15551111", contact.destinations.single().value) + } } } @Test - fun digitFallbackRecoversContactWhenFilterMatchesNothing() = runTest { - stubFilterPhoneCursor(query = "1234", rows = emptyList()) - stubFilterEmailCursor(query = "1234", rows = emptyList()) - stubDefaultPhoneCursor( - rows = listOf( - phoneRow( - contactId = 3L, - sortKey = "Charlie", - number = "+1 (555) 1234567", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + fun digitFallbackRecoversContactWhenFilterMatchesNothing() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubFilterPhoneCursor(query = "1234", rows = emptyList()) + stubFilterEmailCursor(query = "1234", rows = emptyList()) + stubDefaultPhoneCursor( + rows = listOf( + phoneRow( + contactId = 3L, + sortKey = "Charlie", + number = "+1 (555) 1234567", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), ), - ), - ) - stubExpansionPhoneCursor( - rows = listOf( - phoneRow( - contactId = 3L, - sortKey = "Charlie", - number = "+1 (555) 1234567", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 3L, + sortKey = "Charlie", + number = "+1 (555) 1234567", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), ), - ), - ) - stubExpansionEmailCursor(rows = emptyList()) + ) + stubExpansionEmailCursor(rows = emptyList()) - val repo = createRepository() - val page = repo.searchContacts(query = "1234", offset = 0).first() + val repo = createRepository() + val page = repo.searchContacts(query = "1234", offset = 0).first() - Assert.assertEquals(3L, page.contacts.single().id) + Assert.assertEquals(3L, page.contacts.single().id) + } } @Test - fun paginationSplitsContactsAcrossPages() = runTest { - val rows = (1..250).map { id -> - phoneRow( - contactId = id.toLong(), - sortKey = "Person %03d".format(id), - number = "+1555%07d".format(id), - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + fun paginationSplitsContactsAcrossPages() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val rows = (1..250).map { id -> + phoneRow( + contactId = id.toLong(), + sortKey = "Person %03d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubDefaultPhoneCursor(rows = rows) + stubExpansionPhoneCursor( + rows = rows, + ) + stubExpansionEmailCursor( + rows = emptyList(), ) - } - stubDefaultPhoneCursor(rows = rows) - stubExpansionPhoneCursor( - rows = rows, - ) - stubExpansionEmailCursor( - rows = emptyList(), - ) - val repo = createRepository() - val firstPage = repo.searchContacts(query = "", offset = 0).first() - val secondPage = repo.searchContacts(query = "", offset = 200).first() + val repo = createRepository() + val firstPage = repo.searchContacts(query = "", offset = 0).first() + val secondPage = repo.searchContacts(query = "", offset = 200).first() - Assert.assertEquals(200, firstPage.contacts.size) - Assert.assertEquals(200, firstPage.nextOffset) - Assert.assertEquals(50, secondPage.contacts.size) - Assert.assertNull(secondPage.nextOffset) + Assert.assertEquals(200, firstPage.contacts.size) + Assert.assertEquals(200, firstPage.nextOffset) + Assert.assertEquals(50, secondPage.contacts.size) + Assert.assertNull(secondPage.nextOffset) + } } @Test - fun phoneAndEmailDestinationsForSameContactMerge() = runTest { - stubFilterPhoneCursor( - query = "Dee", - rows = listOf( - phoneRow( - contactId = 4L, - sortKey = "Dee", - number = "+14444444", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + fun phoneAndEmailDestinationsForSameContactMerge() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubFilterPhoneCursor( + query = "Dee", + rows = listOf( + phoneRow( + contactId = 4L, + sortKey = "Dee", + number = "+14444444", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), ), - ), - ) - stubFilterEmailCursor(query = "Dee", rows = emptyList()) - stubExpansionPhoneCursor( - rows = listOf( - phoneRow( - contactId = 4L, - sortKey = "Dee", - number = "+14444444", - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + stubFilterEmailCursor(query = "Dee", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 4L, + sortKey = "Dee", + number = "+14444444", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), ), - ), - ) - stubExpansionEmailCursor( - rows = listOf( - emailRow( - contactId = 4L, - sortKey = "Dee", - address = "dee@example.com", - type = ContactsContract.CommonDataKinds.Email.TYPE_WORK, + ) + stubExpansionEmailCursor( + rows = listOf( + emailRow( + contactId = 4L, + sortKey = "Dee", + address = "dee@example.com", + type = ContactsContract.CommonDataKinds.Email.TYPE_WORK, + ), ), - ), - ) - - val repo = createRepository() - val page = repo.searchContacts(query = "Dee", offset = 0).first() + ) - val contact = page.contacts.single() - Assert.assertEquals(2, contact.destinations.size) - val kinds = contact.destinations.map { it.kind } - Assert.assertEquals(ContactDestination.Kind.PHONE, kinds[0]) - Assert.assertEquals(ContactDestination.Kind.EMAIL, kinds[1]) - } + val repo = createRepository() + val page = repo.searchContacts(query = "Dee", offset = 0).first() - @Test - fun largeContactSetGetsChunkedAndMergedEquivalently() = runTest { - val contactCount = 1100 - val ids = (1..contactCount).map { it.toLong() } - val rows = ids.map { id -> - phoneRow( - contactId = id, - sortKey = "Person %05d".format(id), - number = "+1555%07d".format(id), - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - ) + val contact = page.contacts.single() + Assert.assertEquals(2, contact.destinations.size) + val kinds = contact.destinations.map { it.kind } + Assert.assertEquals(ContactDestination.Kind.PHONE, kinds[0]) + Assert.assertEquals(ContactDestination.Kind.EMAIL, kinds[1]) } - stubFilterPhoneCursor(query = "Person", rows = rows) - stubFilterEmailCursor(query = "Person", rows = emptyList()) - stubExpansionPhoneCursor(rows = rows) - stubExpansionEmailCursor(rows = emptyList()) - - val repo = createRepository() - val page = repo.searchContacts(query = "Person", offset = 0).first() - - Assert.assertEquals(200, page.contacts.size) - Assert.assertEquals(200, page.nextOffset) - val firstContact = page.contacts.first() - Assert.assertFalse(firstContact.destinations.isEmpty()) } @Test - fun searchExpandsOnlyPageContactsNotAllMatches() = runTest { - val totalMatches = 600 - val ids = (1..totalMatches).map { it.toLong() } - val rows = ids.map { id -> - phoneRow( - contactId = id, - sortKey = "Person %05d".format(id), - number = "+1555%07d".format(id), - type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, - ) - } - stubFilterPhoneCursor(query = "Person", rows = rows) - stubFilterEmailCursor(query = "Person", rows = emptyList()) + fun searchExpandsOnlyPageContactsNotAllMatches() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val totalMatches = 600 + val ids = (1..totalMatches).map { it.toLong() } + val rows = ids.map { id -> + phoneRow( + contactId = id, + sortKey = "Person %05d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubFilterPhoneCursor(query = "Person", rows = rows) + stubFilterEmailCursor(query = "Person", rows = emptyList()) + + val queriedPhoneContactIds = mutableSetOf() + val queriedEmailContactIds = mutableSetOf() + + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Phone.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = arg?>(3) ?: emptyArray() + selectionArgs.forEach { queriedPhoneContactIds.add(it.toLong()) } + val argSet = selectionArgs.toSet() + val matchingRows = rows.filter { row -> row.contactId.toString() in argSet } + phoneCursor(rows = matchingRows) + } - val queriedPhoneContactIds = mutableSetOf() - val queriedEmailContactIds = mutableSetOf() + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Email.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Email.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = arg?>(3) ?: emptyArray() + selectionArgs.forEach { queriedEmailContactIds.add(it.toLong()) } + emailCursor(rows = emptyList()) + } - every { - contentResolver.query( - match { uri -> uri == ContactsContract.CommonDataKinds.Phone.CONTENT_URI }, - any(), - match { selection -> - selection.startsWith(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) - }, - any(), - isNull(), - ) - } answers { - val selectionArgs = arg?>(3) ?: emptyArray() - selectionArgs.forEach { queriedPhoneContactIds.add(it.toLong()) } - val argSet = selectionArgs.toSet() - val matchingRows = rows.filter { row -> row.contactId.toString() in argSet } - phoneCursor(rows = matchingRows) - } + val repo = createRepository() + val page = repo.searchContacts(query = "Person", offset = 0).first() - every { - contentResolver.query( - match { uri -> uri == ContactsContract.CommonDataKinds.Email.CONTENT_URI }, - any(), - match { selection -> - selection.startsWith(ContactsContract.CommonDataKinds.Email.CONTACT_ID) - }, - any(), - isNull(), - ) - } answers { - val selectionArgs = arg?>(3) ?: emptyArray() - selectionArgs.forEach { queriedEmailContactIds.add(it.toLong()) } - emailCursor(rows = emptyList()) + Assert.assertEquals(200, page.contacts.size) + Assert.assertEquals(200, page.nextOffset) + Assert.assertEquals((1L..200L).toSet(), queriedPhoneContactIds) + Assert.assertEquals((1L..200L).toSet(), queriedEmailContactIds) } - - val repo = createRepository() - val page = repo.searchContacts(query = "Person", offset = 0).first() - - Assert.assertEquals(200, page.contacts.size) - Assert.assertEquals(200, page.nextOffset) - Assert.assertEquals((1L..200L).toSet(), queriedPhoneContactIds) - Assert.assertEquals((1L..200L).toSet(), queriedEmailContactIds) } private fun createRepository(): ContactsRepositoryImpl { return ContactsRepositoryImpl( formatter = ContactDestinationFormatterImpl(), contentResolver = contentResolver, - ioDispatcher = UnconfinedTestDispatcher(), + ioDispatcher = mainDispatcherRule.testDispatcher, ) } diff --git a/app/src/test/kotlin/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGrantedImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGrantedImplTest.kt new file mode 100644 index 000000000..d2c143f75 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGrantedImplTest.kt @@ -0,0 +1,83 @@ +package com.android.messaging.domain.contacts.usecase + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +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) +class IsReadContactsPermissionGrantedImplTest { + + private lateinit var context: Context + + @Before + fun setUp() { + unmockkAll() + mockkStatic(ContextCompat::class) + context = mockk() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun invoke_returnsTrueWhenPermissionIsGranted() { + every { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) + } returns PackageManager.PERMISSION_GRANTED + val useCase = createUseCase() + + val isGranted = useCase.invoke() + + assertEquals(true, isGranted) + verify(exactly = 1) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) + } + } + + @Test + fun invoke_returnsFalseWhenPermissionIsDenied() { + every { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) + } returns PackageManager.PERMISSION_DENIED + val useCase = createUseCase() + + val isGranted = useCase.invoke() + + assertEquals(false, isGranted) + verify(exactly = 1) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) + } + } + + private fun createUseCase(): IsReadContactsPermissionGrantedImpl { + return IsReadContactsPermissionGrantedImpl( + context = context, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirementsImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirementsImplTest.kt new file mode 100644 index 000000000..8edf5ebbd --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirementsImplTest.kt @@ -0,0 +1,106 @@ +package com.android.messaging.domain.conversation.usecase.action + +import android.app.role.RoleManager +import com.android.messaging.util.PhoneUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +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) +class CheckConversationActionRequirementsImplTest { + + private val phoneUtils = mockk() + private val roleManager = mockk() + + @Before + fun setUp() { + mockkStatic(PhoneUtils::class) + every { PhoneUtils.getDefault() } returns phoneUtils + } + + @After + fun tearDown() { + unmockkStatic(PhoneUtils::class) + } + + @Test + fun invoke_whenDeviceIsNotSmsCapable_returnsSmsNotCapable() { + every { phoneUtils.isSmsCapable } returns false + val useCase = CheckConversationActionRequirementsImpl(roleManager = roleManager) + + assertEquals( + ConversationActionRequirementsResult.SmsNotCapable, + useCase(), + ) + } + + @Test + fun invoke_whenPreferredSmsSimIsMissing_returnsNoPreferredSmsSim() { + every { phoneUtils.isSmsCapable } returns true + every { phoneUtils.hasPreferredSmsSim } returns false + val useCase = CheckConversationActionRequirementsImpl(roleManager = roleManager) + + assertEquals( + ConversationActionRequirementsResult.NoPreferredSmsSim, + useCase(), + ) + } + + @Test + fun invoke_whenSmsRoleIsUnavailable_returnsMissingDefaultSmsRole() { + every { phoneUtils.isSmsCapable } returns true + every { phoneUtils.hasPreferredSmsSim } returns true + every { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) + } returns false + val useCase = CheckConversationActionRequirementsImpl(roleManager = roleManager) + + assertEquals( + ConversationActionRequirementsResult.MissingDefaultSmsRole, + useCase(), + ) + } + + @Test + fun invoke_whenSmsRoleIsNotHeld_returnsMissingDefaultSmsRole() { + every { phoneUtils.isSmsCapable } returns true + every { phoneUtils.hasPreferredSmsSim } returns true + every { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) + } returns true + every { + roleManager.isRoleHeld(RoleManager.ROLE_SMS) + } returns false + val useCase = CheckConversationActionRequirementsImpl(roleManager = roleManager) + + assertEquals( + ConversationActionRequirementsResult.MissingDefaultSmsRole, + useCase(), + ) + } + + @Test + fun invoke_whenAllRequirementsAreSatisfied_returnsReady() { + every { phoneUtils.isSmsCapable } returns true + every { phoneUtils.hasPreferredSmsSim } returns true + every { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) + } returns true + every { + roleManager.isRoleHeld(RoleManager.ROLE_SMS) + } returns true + val useCase = CheckConversationActionRequirementsImpl(roleManager = roleManager) + + assertEquals( + ConversationActionRequirementsResult.Ready, + useCase(), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequestImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequestImplTest.kt new file mode 100644 index 000000000..069d38778 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequestImplTest.kt @@ -0,0 +1,45 @@ +package com.android.messaging.domain.conversation.usecase.action + +import android.app.role.RoleManager +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CreateDefaultSmsRoleRequestImplTest { + + @Test + fun invoke_whenSmsRoleIsUnavailable_returnsNull() { + val roleManager = mockk() + every { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) + } returns false + + val useCase = CreateDefaultSmsRoleRequestImpl(roleManager = roleManager) + + assertNull(useCase()) + } + + @Test + fun invoke_whenSmsRoleIsAvailable_returnsRoleRequestIntent() { + val requestIntent = Intent("request-sms-role") + val roleManager = mockk() + + every { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) + } returns true + + every { + roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) + } returns requestIntent + + val useCase = CreateDefaultSmsRoleRequestImpl(roleManager = roleManager) + + assertSame(requestIntent, useCase()) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/forward/createforwardedmessage/CreateForwardedMessageImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/forward/createforwardedmessage/CreateForwardedMessageImplTest.kt new file mode 100644 index 000000000..4fe8ff50b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/forward/createforwardedmessage/CreateForwardedMessageImplTest.kt @@ -0,0 +1,349 @@ +package com.android.messaging.domain.conversation.usecase.forward.createforwardedmessage + +import android.net.Uri +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.datamodel.data.PendingAttachmentData +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessageImpl +import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatter +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CreateForwardedMessageImplTest { + + private val conversationsRepository = mockk() + private val subjectFormatter = mockk() + + @Test + fun invoke_whenMessageDoesNotExist_returnsNull() { + runTest { + coEvery { + conversationsRepository.getConversationMessage( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + } returns null + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + + assertNull(result) + verify(exactly = 0) { + subjectFormatter.format(subject = any()) + } + } + } + + @Test + fun invoke_formatsAndCopiesSubject() { + runTest { + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = emptyList(), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + + assertEquals(FORWARDED_SUBJECT, result?.mmsSubject) + verify(exactly = 1) { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } + } + } + + @Test + fun invoke_whenFormattedSubjectIsNull_keepsSubjectNull() { + runTest { + val sourceMessage = createSourceMessage( + subject = null, + parts = emptyList(), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = null) + } returns null + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + + assertNull(result?.mmsSubject) + verify(exactly = 1) { + subjectFormatter.format(subject = null) + } + } + } + + @Test + fun invoke_whenPartsAreNull_returnsMessageWithoutParts() { + runTest { + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = null, + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + + assertEquals(emptyList(), result?.parts?.toList()) + } + } + + @Test + fun invoke_whenPartsAreEmpty_returnsMessageWithoutParts() { + runTest { + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = emptyList(), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + + assertEquals(emptyList(), result?.parts?.toList()) + } + } + + @Test + fun invoke_copiesTextPartsIntoNewTextPartsInOrder() { + runTest { + val firstTextPart = createTextPart(text = "First") + val secondTextPart = createTextPart(text = "Second") + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = listOf(firstTextPart, secondTextPart), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + val resultParts = result?.parts?.toList().orEmpty() + + assertEquals(2, resultParts.size) + assertNotSame(firstTextPart, resultParts[0]) + assertNotSame(secondTextPart, resultParts[1]) + assertEquals("First", resultParts[0].text) + assertEquals("Second", resultParts[1].text) + assertTrue(resultParts[0].isText) + assertTrue(resultParts[1].isText) + assertNull(resultParts[0].contentUri) + assertNull(resultParts[1].contentUri) + } + } + + @Test + fun invoke_convertsAttachmentPartsIntoPendingAttachmentsInOrder() { + runTest { + val imageUri = Uri.parse(IMAGE_URI) + val videoUri = Uri.parse(VIDEO_URI) + val imagePart = createAttachmentPart( + contentType = IMAGE_CONTENT_TYPE, + contentUri = imageUri, + ) + val videoPart = createAttachmentPart( + contentType = VIDEO_CONTENT_TYPE, + contentUri = videoUri, + ) + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = listOf(imagePart, videoPart), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + val resultParts = result?.parts?.toList().orEmpty() + + assertEquals(2, resultParts.size) + assertPendingAttachment( + part = resultParts[0], + contentType = IMAGE_CONTENT_TYPE, + contentUri = imageUri, + ) + assertPendingAttachment( + part = resultParts[1], + contentType = VIDEO_CONTENT_TYPE, + contentUri = videoUri, + ) + } + } + + @Test + fun invoke_copiesMixedPartsInSourceOrder() { + runTest { + val contentUri = Uri.parse(IMAGE_URI) + val textPart = createTextPart(text = "Before") + val attachmentPart = createAttachmentPart( + contentType = IMAGE_CONTENT_TYPE, + contentUri = contentUri, + ) + val trailingTextPart = createTextPart(text = "After") + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = listOf(textPart, attachmentPart, trailingTextPart), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + val result = createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + val resultParts = result?.parts?.toList().orEmpty() + + assertEquals(3, resultParts.size) + assertEquals("Before", resultParts[0].text) + assertPendingAttachment( + part = resultParts[1], + contentType = IMAGE_CONTENT_TYPE, + contentUri = contentUri, + ) + assertEquals("After", resultParts[2].text) + } + } + + @Test + fun invoke_queriesRepositoryWithRequestedIds() { + runTest { + val sourceMessage = createSourceMessage( + subject = ORIGINAL_SUBJECT, + parts = emptyList(), + ) + stubMessage(sourceMessage = sourceMessage) + every { + subjectFormatter.format(subject = ORIGINAL_SUBJECT) + } returns FORWARDED_SUBJECT + + createUseCase().invoke( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + + coVerify(exactly = 1) { + conversationsRepository.getConversationMessage( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + } + } + } + + private fun stubMessage(sourceMessage: ConversationMessageData?) { + coEvery { + conversationsRepository.getConversationMessage( + conversationId = CONVERSATION_ID, + messageId = MESSAGE_ID, + ) + } returns sourceMessage + } + + private fun createSourceMessage( + subject: String?, + parts: List?, + ): ConversationMessageData { + val message = mockk() + every { message.mmsSubject } returns subject + every { message.parts } returns parts + return message + } + + private fun createTextPart(text: String): MessagePartData { + val part = mockk() + every { part.isText } returns true + every { part.text } returns text + return part + } + + private fun createAttachmentPart( + contentType: String, + contentUri: Uri, + ): MessagePartData { + val part = mockk() + every { part.isText } returns false + every { part.contentType } returns contentType + every { part.contentUri } returns contentUri + return part + } + + private fun assertPendingAttachment( + part: MessagePartData, + contentType: String, + contentUri: Uri, + ) { + assertTrue(part is PendingAttachmentData) + assertEquals(contentType, part.contentType) + assertEquals(contentUri, part.contentUri) + assertEquals(MessagePartData.UNSPECIFIED_SIZE, part.width) + assertEquals(MessagePartData.UNSPECIFIED_SIZE, part.height) + assertNull(part.text) + assertEquals( + PendingAttachmentData.STATE_PENDING, + (part as PendingAttachmentData).currentState + ) + } + + private fun createUseCase(): CreateForwardedMessageImpl { + return CreateForwardedMessageImpl( + conversationsRepository = conversationsRepository, + forwardedMessageSubjectFormatter = subjectFormatter, + ) + } + + private companion object { + private const val CONVERSATION_ID = "conversation-1" + private const val FORWARDED_SUBJECT = "Fwd: Original subject" + private const val IMAGE_CONTENT_TYPE = "image/jpeg" + private const val IMAGE_URI = "content://media/image/1" + private const val MESSAGE_ID = "message-1" + private const val ORIGINAL_SUBJECT = "Original subject" + private const val VIDEO_CONTENT_TYPE = "video/mp4" + private const val VIDEO_URI = "content://media/video/1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipantsImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipantsImplTest.kt new file mode 100644 index 000000000..788804d55 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipantsImplTest.kt @@ -0,0 +1,51 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import com.android.messaging.datamodel.data.ContactPickerData +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CanAddMoreConversationParticipantsImplTest { + + @Before + fun setUp() { + unmockkAll() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun invoke_delegatesToLegacyContactPickerLimitCheck() { + val useCase = CanAddMoreConversationParticipantsImpl() + mockkStatic(ContactPickerData::class) + every { ContactPickerData.getCanAddMoreParticipants(0) } returns true + every { ContactPickerData.getCanAddMoreParticipants(4) } returns true + every { ContactPickerData.getCanAddMoreParticipants(5) } returns false + + assertTrue(useCase.invoke(participantCount = 0)) + assertTrue(useCase.invoke(participantCount = 4)) + assertFalse(useCase.invoke(participantCount = 5)) + + verify(exactly = 1) { + ContactPickerData.getCanAddMoreParticipants(0) + } + verify(exactly = 1) { + ContactPickerData.getCanAddMoreParticipants(4) + } + verify(exactly = 1) { + ContactPickerData.getCanAddMoreParticipants(5) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationIdImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationIdImplTest.kt new file mode 100644 index 000000000..605d5516c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationIdImplTest.kt @@ -0,0 +1,203 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import com.android.messaging.datamodel.action.GetOrCreateConversationAction +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.testutil.MainDispatcherRule +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ResolveConversationIdImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Before + fun setUp() { + unmockkAll() + mockkStatic(GetOrCreateConversationAction::class) + mockkStatic(ParticipantData::class) + every { ParticipantData.getFromRawPhoneBySystemLocale(any()) } answers { + val destination = firstArg() + mockk { + every { sendDestination } returns destination + } + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun invoke_returnsEmptyDestinationsWhenAllRecipientsAreBlank() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val useCase = createUseCase() + + val result = useCase.invoke( + destinations = listOf(" ", "\n"), + ) + + assertEquals(ResolveConversationIdResult.EmptyDestinations, result) + verify(exactly = 0) { + GetOrCreateConversationAction.getOrCreateConversation( + any>(), + any(), + any(), + ) + } + } + } + + @Test + fun invoke_trimsDestinationsAndReturnsResolvedConversation() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val capturedParticipants = slot>() + val capturedListener = + slot() + val monitor = mockk( + relaxed = true, + ) + every { + GetOrCreateConversationAction.getOrCreateConversation( + capture(capturedParticipants), + any(), + capture(capturedListener), + ) + } answers { + capturedListener.captured.onGetOrCreateConversationSucceeded( + monitor, + null, + "conversation-123", + ) + monitor + } + val useCase = createUseCase() + + val result = useCase.invoke( + destinations = listOf(" 123 ", " alice@example.com ", ""), + ) + + assertEquals( + ResolveConversationIdResult.Resolved( + conversationId = "conversation-123", + ), + result, + ) + assertEquals(2, capturedParticipants.captured.size) + assertEquals("123", capturedParticipants.captured[0].sendDestination) + assertEquals("alice@example.com", capturedParticipants.captured[1].sendDestination) + verify(exactly = 1) { + GetOrCreateConversationAction.getOrCreateConversation( + any>(), + any(), + any(), + ) + } + } + } + + @Test + fun invoke_returnsNotResolvedWhenActionFails() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val capturedListener = + slot() + val monitor = mockk( + relaxed = true, + ) + every { + GetOrCreateConversationAction.getOrCreateConversation( + any>(), + any(), + capture(capturedListener), + ) + } answers { + capturedListener.captured.onGetOrCreateConversationFailed( + monitor, + null, + ) + monitor + } + val useCase = createUseCase() + + val result = useCase.invoke( + destinations = listOf("123"), + ) + + assertEquals(ResolveConversationIdResult.NotResolved, result) + verify(exactly = 1) { + GetOrCreateConversationAction.getOrCreateConversation( + any>(), + any(), + any(), + ) + } + } + } + + @Test + fun invoke_unregistersActionMonitorWhenCancelled() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val monitor = + mockk() + every { + monitor.unregister() + } just runs + every { + GetOrCreateConversationAction.getOrCreateConversation( + any>(), + any(), + any(), + ) + } returns monitor + val useCase = createUseCase() + + val deferred = async { + useCase.invoke( + destinations = listOf("123"), + ) + } + advanceUntilIdle() + deferred.cancel() + advanceUntilIdle() + + verify(exactly = 1) { + monitor.unregister() + } + } + } + + private fun createUseCase(): ResolveConversationIdImpl { + return ResolveConversationIdImpl( + mainDispatcher = mainDispatcherRule.testDispatcher, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumberImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumberImplTest.kt new file mode 100644 index 000000000..a06b84a46 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumberImplTest.kt @@ -0,0 +1,68 @@ +package com.android.messaging.domain.conversation.usecase.telephony + +import android.telephony.TelephonyManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class IsEmergencyPhoneNumberImplTest { + + private val telephonyManager = mockk() + private val isEmergencyPhoneNumber = IsEmergencyPhoneNumberImpl( + telephonyManager = telephonyManager, + ) + + @Test + fun invoke_stripsSeparatorsBeforeCheckingTelephonyManager() { + every { telephonyManager.isEmergencyNumber(EMERGENCY_NUMBER) } returns true + + val result = isEmergencyPhoneNumber("9-1-1") + + assertTrue(result) + verify(exactly = 1) { + telephonyManager.isEmergencyNumber(EMERGENCY_NUMBER) + } + } + + @Test + fun invoke_returnsFalseWhenTelephonyManagerRejectsNumber() { + every { telephonyManager.isEmergencyNumber(NON_EMERGENCY_NUMBER) } returns false + + val result = isEmergencyPhoneNumber(NON_EMERGENCY_NUMBER) + + assertFalse(result) + } + + @Test + fun invoke_fallsBackWhenTelephonyManagerStateIsUnavailable() { + every { + telephonyManager.isEmergencyNumber(EMERGENCY_NUMBER) + } throws IllegalStateException("not ready") + + val result = isEmergencyPhoneNumber(EMERGENCY_NUMBER) + + assertTrue(result) + } + + @Test + fun invoke_fallsBackWhenTelephonyManagerDoesNotSupportEmergencyChecks() { + every { + telephonyManager.isEmergencyNumber(NON_EMERGENCY_NUMBER) + } throws UnsupportedOperationException("unsupported") + + val result = isEmergencyPhoneNumber(NON_EMERGENCY_NUMBER) + + assertFalse(result) + } + + private companion object { + private const val EMERGENCY_NUMBER = "911" + private const val NON_EMERGENCY_NUMBER = "5551212" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/sms/MmsSubjectSanitizerTest.kt b/app/src/test/kotlin/com/android/messaging/sms/MmsSubjectSanitizerTest.kt new file mode 100644 index 000000000..056aada67 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/sms/MmsSubjectSanitizerTest.kt @@ -0,0 +1,55 @@ +package com.android.messaging.sms + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MmsSubjectSanitizerTest { + + @Test + fun cleanseMmsSubject_returnsNullForNullSubject() { + val cleansedSubject = cleanseMmsSubject( + subject = null, + emptySubjectStrings = EMPTY_SUBJECT_STRINGS, + ) + + assertNull(cleansedSubject) + } + + @Test + fun cleanseMmsSubject_returnsNullForEmptySubject() { + val cleansedSubject = cleanseMmsSubject( + subject = "", + emptySubjectStrings = EMPTY_SUBJECT_STRINGS, + ) + + assertNull(cleansedSubject) + } + + @Test + fun cleanseMmsSubject_returnsNullForConfiguredNoSubjectValue_ignoringCase() { + val cleansedSubject = cleanseMmsSubject( + subject = "No Subject", + emptySubjectStrings = EMPTY_SUBJECT_STRINGS, + ) + + assertNull(cleansedSubject) + } + + @Test + fun cleanseMmsSubject_returnsOriginalSubjectForRealSubject() { + val cleansedSubject = cleanseMmsSubject( + subject = "Trip details", + emptySubjectStrings = EMPTY_SUBJECT_STRINGS, + ) + + assertEquals("Trip details", cleansedSubject) + } + + private companion object { + private val EMPTY_SUBJECT_STRINGS = arrayOf( + "no subject", + "nosubject", + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinFlowExtensionsTest.kt b/app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinFlowExtensionsTest.kt new file mode 100644 index 000000000..0428337ee --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinFlowExtensionsTest.kt @@ -0,0 +1,45 @@ +package com.android.messaging.util.core.extension + +import app.cash.turbine.test +import com.android.messaging.testutil.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class KotlinFlowExtensionsTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun typedFlow_emitsReturnedValue() { + runTest(context = mainDispatcherRule.testDispatcher) { + typedFlow { + emit("before") + return@typedFlow "after" + }.test { + assertEquals("before", awaitItem()) + assertEquals("after", awaitItem()) + awaitComplete() + } + } + } + + @Test + fun unitFlow_runsBlockThenEmitsUnit() { + runTest(context = mainDispatcherRule.testDispatcher) { + val collectedValues = mutableListOf() + + unitFlow { + collectedValues += "executed" + }.test { + assertEquals(listOf("executed"), collectedValues) + assertEquals(Unit, awaitItem()) + awaitComplete() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/util/db/ReversedCursorTest.kt b/app/src/test/kotlin/com/android/messaging/util/db/ReversedCursorTest.kt new file mode 100644 index 000000000..54564f3b8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/util/db/ReversedCursorTest.kt @@ -0,0 +1,105 @@ +package com.android.messaging.util.db + +import android.database.MatrixCursor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ReversedCursorTest { + + @Test + fun reversedCursor_iteratesRowsInReverseOrder() { + val reversedCursor = createReversedCursor( + rows = listOf("first", "second", "third"), + ) + + reversedCursor.use { cursor -> + assertTrue(cursor.isBeforeFirst) + assertEquals(-1, cursor.position) + + assertTrue(cursor.moveToNext()) + assertEquals("third", cursor.getString(VALUE_COLUMN_INDEX)) + assertTrue(cursor.isFirst) + assertFalse(cursor.isLast) + assertEquals(0, cursor.position) + + assertTrue(cursor.moveToNext()) + assertEquals("second", cursor.getString(VALUE_COLUMN_INDEX)) + assertFalse(cursor.isFirst) + assertFalse(cursor.isLast) + assertEquals(1, cursor.position) + + assertTrue(cursor.moveToNext()) + assertEquals("first", cursor.getString(VALUE_COLUMN_INDEX)) + assertFalse(cursor.isFirst) + assertTrue(cursor.isLast) + assertEquals(2, cursor.position) + + assertFalse(cursor.moveToNext()) + assertTrue(cursor.isAfterLast) + assertEquals(3, cursor.position) + } + } + + @Test + fun reversedCursor_supportsRandomAccessAndReverseNavigation() { + val reversedCursor = createReversedCursor( + rows = listOf("first", "second", "third"), + ) + + reversedCursor.use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("third", cursor.getString(VALUE_COLUMN_INDEX)) + + assertTrue(cursor.moveToLast()) + assertEquals("first", cursor.getString(VALUE_COLUMN_INDEX)) + + assertTrue(cursor.moveToPosition(1)) + assertEquals("second", cursor.getString(VALUE_COLUMN_INDEX)) + assertEquals(1, cursor.position) + + assertTrue(cursor.move(1)) + assertEquals("first", cursor.getString(VALUE_COLUMN_INDEX)) + assertTrue(cursor.isLast) + + assertTrue(cursor.move(-2)) + assertEquals("third", cursor.getString(VALUE_COLUMN_INDEX)) + assertTrue(cursor.isFirst) + + assertTrue(cursor.moveToPosition(0)) + assertFalse(cursor.moveToPrevious()) + assertTrue(cursor.isBeforeFirst) + assertEquals(-1, cursor.position) + } + } + + @Test + fun reversedCursor_handlesEmptyCursor() { + val reversedCursor = createReversedCursor(rows = emptyList()) + + reversedCursor.use { cursor -> + assertTrue(cursor.isBeforeFirst) + assertFalse(cursor.moveToFirst()) + assertFalse(cursor.moveToNext()) + assertTrue(cursor.isAfterLast) + assertEquals(0, cursor.position) + } + } + + private fun createReversedCursor(rows: List): ReversedCursor { + val cursor = MatrixCursor(arrayOf(VALUE_COLUMN_NAME)) + for (value in rows) { + cursor.addRow(arrayOf(value)) + } + return ReversedCursor(cursor = cursor) + } + + private companion object { + private const val VALUE_COLUMN_NAME = "value" + private const val VALUE_COLUMN_INDEX = 0 + } +} From 6226055dbb90d758d405bcabe2eab34a86de5ebe Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:35:04 +0300 Subject: [PATCH 04/38] Add data layer unit tests --- .../store/ConversationDraftStoreTest.kt | 53 -- ...versationDraftMessageDataMapperImplTest.kt | 166 +++++ ...versationMessageDataDraftMapperImplTest.kt | 203 ++++++ ...ConversationVCardMetadataMapperImplTest.kt | 124 ++++ .../ConversationDraftsRepositoryImplTest.kt | 436 ++++++++++++ ...versationParticipantsRepositoryImplTest.kt | 288 ++++++++ .../BaseConversationsRepositoryTest.kt | 72 ++ .../ConversationsRepositoryActionsTest.kt | 126 ++++ ...ConversationsRepositoryDirectLookupTest.kt | 389 +++++++++++ .../ConversationsRepositoryMessagesTest.kt | 624 ++++++++++++++++++ .../ConversationsRepositoryMetadataTest.kt | 351 ++++++++++ .../store/ConversationDraftStoreTest.kt | 78 +++ .../ConversationMediaRepositoryImplTest.kt | 230 +++++++ ...nAttachmentsRepositoryVideoMetadataTest.kt | 325 +++++++++ .../SubscriptionsRepositoryImplTest.kt | 587 ++++++++++++++++ .../SubscriptionSettingsRepositoryImplTest.kt | 393 +++++++++++ 16 files changed, 4392 insertions(+), 53 deletions(-) delete mode 100644 app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapperImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapperImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationParticipantsRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryDirectLookupTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/media/repository/conversationattachments/ConversationAttachmentsRepositoryVideoMetadataTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/subscriptionsettings/repository/subscriptionsettings/SubscriptionSettingsRepositoryImplTest.kt diff --git a/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt b/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt deleted file mode 100644 index 7f5f1cfae..000000000 --- a/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.android.messaging.data.conversation.store - -import com.android.messaging.datamodel.DataModel -import com.android.messaging.datamodel.DatabaseWrapper -import com.android.messaging.datamodel.data.ConversationListItemData -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkAll -import org.junit.After -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test - -internal class ConversationDraftStoreTest { - - private val databaseWrapper = mockk() - private val dataModel = mockk() - - private val store = ConversationDraftStoreImpl() - - @Before - fun setUp() { - mockkStatic(DataModel::class) - mockkStatic(ConversationListItemData::class) - - every { DataModel.get() } returns dataModel - every { dataModel.database } returns databaseWrapper - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun getSelfParticipantIdReturnsNullWhenConversationSelfIdIsMissing() { - val conversation = ConversationListItemData() - every { - ConversationListItemData.getExistingConversation(databaseWrapper, CONVERSATION_ID) - } returns conversation - - val selfParticipantId = store.getSelfParticipantId( - conversationId = CONVERSATION_ID, - ) - - assertNull(selfParticipantId) - } - - private companion object { - private const val CONVERSATION_ID = "conversation-id" - } -} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapperImplTest.kt new file mode 100644 index 000000000..bee52b37a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapperImplTest.kt @@ -0,0 +1,166 @@ +package com.android.messaging.data.conversation.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationDraftMessageDataMapperImplTest { + + private val mapper = ConversationDraftMessageDataMapperImpl() + + @Test + fun map_createsDraftSmsMessageForPlainTextDraft() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "self-1", + ), + ) + val parts = message.parts.toList() + + assertEquals(CONVERSATION_ID, message.conversationId) + assertEquals("self-1", message.selfId) + assertEquals("self-1", message.participantId) + assertEquals(MessageData.PROTOCOL_SMS, message.protocol) + assertEquals("Hello", message.messageText) + assertEquals(1, parts.size) + assertTrue(parts.single().isText) + } + + @Test + fun map_createsDraftMmsMessageForSubjectOnlyDraft() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + subjectText = "Subject", + selfParticipantId = "self-1", + ), + ) + val parts = message.parts.toList() + + assertEquals(MessageData.PROTOCOL_MMS, message.protocol) + assertEquals("Subject", message.mmsSubject) + assertEquals("", message.messageText) + assertTrue(parts.isEmpty()) + } + + @Test + fun map_keepsSelfAndParticipantUnsetWhenSelfParticipantIdIsBlank() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "", + ), + ) + + assertNull(message.selfId) + assertNull(message.participantId) + } + + @Test + fun map_createsDraftMmsMessageWhenForced() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "self-1", + ), + forceMms = true, + ) + + assertEquals(MessageData.PROTOCOL_MMS, message.protocol) + assertEquals("Hello", message.messageText) + assertTrue(message.parts.toList().single().isText) + } + + @Test + fun map_createsMediaPartForValidAttachment() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = IMAGE_CONTENT_URI, + width = 640, + height = 480, + ), + ), + ), + ) + + val attachmentPart = message.parts.toList().single() + + assertEquals(MessageData.PROTOCOL_MMS, message.protocol) + assertTrue(attachmentPart.isImage) + assertEquals("image/jpeg", attachmentPart.contentType) + assertEquals(IMAGE_CONTENT_URI, attachmentPart.contentUri.toString()) + assertEquals(640, attachmentPart.width) + assertEquals(480, attachmentPart.height) + assertNull(attachmentPart.text) + } + + @Test + fun map_createsCaptionedMediaPartWithUnspecifiedDimensions() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = IMAGE_CONTENT_URI, + captionText = "Caption", + ), + ), + ), + ) + + val attachmentPart = message.parts.toList().single() + + assertEquals("Caption", attachmentPart.text) + assertEquals(MessagePartData.UNSPECIFIED_SIZE, attachmentPart.width) + assertEquals(MessagePartData.UNSPECIFIED_SIZE, attachmentPart.height) + } + + @Test + fun map_dropsAttachmentsWithBlankRequiredFields() { + val message = mapper.map( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "", + contentUri = IMAGE_CONTENT_URI, + ), + ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "", + ), + ), + ), + ) + + val parts = message.parts.toList() + + assertEquals(MessageData.PROTOCOL_SMS, message.protocol) + assertEquals(1, parts.size) + assertTrue(parts.single().isText) + } + + private companion object { + private const val IMAGE_CONTENT_URI = "content://mms/part/image-1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt new file mode 100644 index 000000000..3e786e3df --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt @@ -0,0 +1,203 @@ +package com.android.messaging.data.conversation.mapper + +import android.net.Uri +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationMessageDataDraftMapperImplTest { + + private val mapper = ConversationMessageDataDraftMapperImpl() + + @Test + fun map_preservesSourceFieldsWhenSelfParticipantIdIsPresent() { + val messageData = MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + ) + messageData.mmsSubject = "Subject" + messageData.addPart( + MessagePartData.createMediaMessagePart( + "Caption", + "image/jpeg", + Uri.parse("content://media/image/1"), + 640, + 480, + ), + ) + + val draft = mapper.map( + messageData = messageData, + fallbackSelfParticipantId = "fallback-self", + ) + + assertEquals("Hello", draft.messageText) + assertEquals("Subject", draft.subjectText) + assertEquals("self-1", draft.selfParticipantId) + assertEquals( + listOf( + createAttachment( + contentType = "image/jpeg", + contentUri = "content://media/image/1", + captionText = "Caption", + width = 640, + height = 480, + ), + ), + draft.attachments, + ) + } + + @Test + fun map_usesFallbackSelfParticipantIdWhenSourceSelfIdIsNull() { + val messageData = MessageData.createDraftMessage( + CONVERSATION_ID, + null, + null, + ) + + val draft = mapper.map( + messageData = messageData, + fallbackSelfParticipantId = "fallback-self", + ) + + assertEquals("fallback-self", draft.selfParticipantId) + } + + @Test + fun map_usesFallbackSelfParticipantIdWhenSourceSelfIdIsBlank() { + val messageData = MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "", + "Hello", + ) + + val draft = mapper.map( + messageData = messageData, + fallbackSelfParticipantId = "fallback-self", + ) + + assertEquals("fallback-self", draft.selfParticipantId) + } + + @Test + fun map_normalizesUnspecifiedAttachmentDimensionsToNull() { + val messageData = MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "", + ) + messageData.addPart( + MessagePartData.createMediaMessagePart( + "image/png", + Uri.parse("content://media/image/2"), + MessagePartData.UNSPECIFIED_SIZE, + MessagePartData.UNSPECIFIED_SIZE, + ), + ) + + val draft = mapper.map(messageData = messageData) + val attachment = draft.attachments.single() + + assertEquals("image/png", attachment.contentType) + assertEquals("content://media/image/2", attachment.contentUri) + assertEquals("", attachment.captionText) + assertNull(attachment.width) + assertNull(attachment.height) + } + + @Test + fun map_dropsAttachmentsWithBlankContentTypeOrUri() { + val messageData = MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + ) + messageData.addPart( + MessagePartData.createMediaMessagePart( + "", + Uri.parse("content://media/image/3"), + 320, + 240, + ), + ) + messageData.addPart( + MessagePartData.createMediaMessagePart( + "image/jpeg", + Uri.EMPTY, + 800, + 600, + ), + ) + messageData.addPart( + MessagePartData.createMediaMessagePart( + "audio/mp3", + Uri.parse("content://media/audio/4"), + 0, + 0, + ), + ) + + val draft = mapper.map(messageData = messageData) + + assertEquals( + listOf( + createAttachment( + contentType = "audio/mp3", + contentUri = "content://media/audio/4", + width = 0, + height = 0, + ), + ), + draft.attachments, + ) + } + + @Test + fun map_dropsAttachmentsBackedByPhotoPickerUris() { + val messageData = MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + ) + messageData.addPart( + MessagePartData.createMediaMessagePart( + "image/jpeg", + Uri.parse( + "content://media/picker/0/" + + "com.android.providers.media.photopicker/media/1", + ), + 320, + 240, + ), + ) + + val draft = mapper.map(messageData = messageData) + + assertEquals(emptyList(), draft.attachments) + } + + private fun createAttachment( + contentType: String, + contentUri: String, + captionText: String = "", + width: Int? = null, + height: Int? = null, + ): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = captionText, + width = width, + height = height, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapperImplTest.kt new file mode 100644 index 000000000..23715e7e8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapperImplTest.kt @@ -0,0 +1,124 @@ +package com.android.messaging.data.conversation.mapper + +import android.net.Uri +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.datamodel.data.VCardContactItemData +import com.android.messaging.datamodel.media.VCardResource +import com.android.messaging.datamodel.media.VCardResourceEntry +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationVCardMetadataMapperImplTest { + + private val mapper = ConversationVCardMetadataMapperImpl() + + @Test + fun map_contactEntry_returnsContactMetadata() { + val vCardContactItemData = mockk { + every { getDisplayName() } returns "Sam Rivera" + every { avatarUri } returns Uri.parse("content://avatar/sam") + every { details } returns "sam@example.com" + every { vCardResource } returns vCardResource( + entry = vCardResourceEntry( + kind = null, + displayAddress = null, + ), + ) + } + + val metadata = mapper.map( + vCardContactItemData = vCardContactItemData, + ) + + assertEquals( + ConversationVCardAttachmentMetadata.Loaded( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = "content://avatar/sam", + displayName = "Sam Rivera", + details = "sam@example.com", + locationAddress = null, + ), + metadata, + ) + } + + @Test + fun map_locationEntry_returnsLocationMetadata() { + val vCardContactItemData = mockk { + every { getDisplayName() } returns null + every { avatarUri } returns null + every { details } returns "New York" + every { vCardResource } returns vCardResource( + entry = vCardResourceEntry( + kind = "LoCaTiOn", + displayAddress = "25 11th Ave New York NY 10011 United States", + ), + ) + } + + val metadata = mapper.map( + vCardContactItemData = vCardContactItemData, + ) + + assertEquals( + ConversationVCardAttachmentMetadata.Loaded( + type = ConversationVCardAttachmentType.LOCATION, + avatarUri = null, + displayName = null, + details = "New York", + locationAddress = "25 11th Ave New York NY 10011 United States", + ), + metadata, + ) + } + + @Test + fun map_blankStrings_returnsNullFields() { + val vCardContactItemData = mockk { + every { getDisplayName() } returns " " + every { avatarUri } returns Uri.parse(" ") + every { details } returns "" + every { vCardResource } returns vCardResource( + entry = vCardResourceEntry( + kind = null, + displayAddress = " ", + ), + ) + } + + val metadata = mapper.map( + vCardContactItemData = vCardContactItemData, + ) as ConversationVCardAttachmentMetadata.Loaded + + assertEquals(ConversationVCardAttachmentType.CONTACT, metadata.type) + assertNull(metadata.avatarUri) + assertNull(metadata.displayName) + assertNull(metadata.details) + assertNull(metadata.locationAddress) + } + + private fun vCardResource( + entry: VCardResourceEntry, + ): VCardResource { + return mockk { + every { vCards } returns listOf(entry) + } + } + + private fun vCardResourceEntry( + kind: String?, + displayAddress: String?, + ): VCardResourceEntry { + return mockk { + every { getKind() } returns kind + every { getDisplayAddress() } returns displayAddress + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt new file mode 100644 index 000000000..22088e324 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt @@ -0,0 +1,436 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.media.MediaMetadataRetriever +import android.net.Uri +import app.cash.turbine.test +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapperImpl +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapperImpl +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.store.ConversationDraftStore +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.util.MediaMetadataRetrieverWrapper +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.unmockkConstructor +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationDraftsRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var contentResolver: ContentResolver + private lateinit var conversationDraftStore: ConversationDraftStore + + @Before + fun setUp() { + contentResolver = mockk() + conversationDraftStore = mockk() + mockkStatic(MessagingContentProvider::class) + every { + MessagingContentProvider.buildConversationMetadataUri(any()) + } answers { + Uri.parse("content://conversation/${firstArg()}/metadata") + } + every { + MessagingContentProvider.notifyConversationMetadataChanged(any()) + } just runs + } + + @After + fun tearDown() { + unmockkConstructor(MediaMetadataRetrieverWrapper::class) + unmockkAll() + } + + @Test + fun observeConversationDraft_registersAndUnregistersObserverForCollection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider + .buildConversationMetadataUri(CONVERSATION_ID) + val repository = createRepository() + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns "self-1" + every { + conversationDraftStore.readDraftMessage( + conversationId = CONVERSATION_ID, + selfParticipantId = "self-1", + ) + } returns MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + ) + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + + repository.observeConversationDraft(conversationId = CONVERSATION_ID).test { + assertEquals("Hello", awaitItem().messageText) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + contentResolver.registerContentObserver( + expectedUri, + true, + registeredObserver.captured, + ) + } + verify(exactly = 1) { + contentResolver.unregisterContentObserver(registeredObserver.captured) + } + } + } + + @Test + fun observeConversationDraft_reloadsDraftWhenObserverChanges() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val repository = createRepository() + var currentDraftMessage: MessageData? = MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "Before", + ) + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns "self-1" + every { + conversationDraftStore.readDraftMessage( + conversationId = CONVERSATION_ID, + selfParticipantId = "self-1", + ) + } answers { + currentDraftMessage + } + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + + repository.observeConversationDraft(conversationId = CONVERSATION_ID).test { + assertEquals("Before", awaitItem().messageText) + + currentDraftMessage = MessageData.createDraftMmsMessage( + CONVERSATION_ID, + "self-1", + "", + "Updated subject", + ) + registeredObserver.captured.onChange(false) + + val updatedDraft = awaitItem() + assertEquals("Updated subject", updatedDraft.subjectText) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeConversationDraft_emitsEmptyDraftWhenConversationDoesNotExist() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val repository = createRepository() + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns null + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + + repository.observeConversationDraft(conversationId = CONVERSATION_ID).test { + assertEquals(ConversationDraft(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeConversationDraft_emitsSafeEmptyDraftWhenLoadingFails() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val repository = createRepository() + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } throws IllegalStateException("boom") + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + + repository.observeConversationDraft(conversationId = CONVERSATION_ID).test { + assertEquals(ConversationDraft(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeConversationDraft_resolvesAudioAttachmentDuration() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val repository = createRepository() + + mockkConstructor(MediaMetadataRetrieverWrapper::class) + every { + anyConstructed().setDataSource(any()) + } just runs + every { + anyConstructed().extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION, + ) + } returns "3210" + every { + anyConstructed().release() + } just runs + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns "self-1" + every { + conversationDraftStore.readDraftMessage( + conversationId = CONVERSATION_ID, + selfParticipantId = "self-1", + ) + } returns createDraftAudioMessageData() + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + + repository.observeConversationDraft(conversationId = CONVERSATION_ID).test { + assertEquals( + 3210L, + awaitItem().attachments.single().durationMillis, + ) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + anyConstructed().setDataSource(any()) + } + } + } + + @Test + fun observeConversationDraft_skipsAudioMetadataResolverWhenDraftHasNoAudioAttachments() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val repository = createRepository() + + mockkConstructor(MediaMetadataRetrieverWrapper::class) + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns "self-1" + every { + conversationDraftStore.readDraftMessage( + conversationId = CONVERSATION_ID, + selfParticipantId = "self-1", + ) + } returns createDraftImageMessageData() + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + + repository.observeConversationDraft(conversationId = CONVERSATION_ID).test { + assertEquals("image/jpeg", awaitItem().attachments.single().contentType) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 0) { + anyConstructed().setDataSource(any()) + } + } + } + + @Test + fun saveDraft_bindsMissingParticipantsAndNotifiesMetadata() { + runTest(context = mainDispatcherRule.testDispatcher) { + val updatedMessage = slot() + val repository = createRepository() + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns "self-1" + every { + conversationDraftStore.updateDraftMessage( + conversationId = CONVERSATION_ID, + message = capture(updatedMessage), + ) + } just runs + + repository.saveDraft( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "", + ), + ) + + assertEquals("self-1", updatedMessage.captured.selfId) + assertEquals("self-1", updatedMessage.captured.participantId) + verify(exactly = 1) { + MessagingContentProvider.notifyConversationMetadataChanged(CONVERSATION_ID) + } + } + } + + @Test + fun saveDraft_returnsWithoutPersistingWhenConversationWasDeletedBeforeBinding() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = createRepository() + + every { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } returns null + + repository.saveDraft( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "", + ), + ) + + verify(exactly = 0) { + conversationDraftStore.updateDraftMessage(any(), any()) + } + verify(exactly = 0) { + MessagingContentProvider.notifyConversationMetadataChanged(any()) + } + } + } + + @Test + fun saveDraft_preservesProvidedSelfParticipantIdWithoutStoreLookup() { + runTest(context = mainDispatcherRule.testDispatcher) { + val updatedMessage = slot() + val repository = createRepository() + + every { + conversationDraftStore.updateDraftMessage( + conversationId = CONVERSATION_ID, + message = capture(updatedMessage), + ) + } just runs + + repository.saveDraft( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "self-2", + ), + ) + + assertEquals("self-2", updatedMessage.captured.selfId) + assertEquals("self-2", updatedMessage.captured.participantId) + verify(exactly = 0) { + conversationDraftStore.getSelfParticipantId(CONVERSATION_ID) + } + } + } + + private fun createRepository(): ConversationDraftsRepositoryImpl { + return ConversationDraftsRepositoryImpl( + contentResolver = contentResolver, + conversationDraftMessageDataMapper = ConversationDraftMessageDataMapperImpl(), + conversationMessageDataDraftMapper = ConversationMessageDataDraftMapperImpl(), + conversationDraftStore = conversationDraftStore, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ioDispatcher = mainDispatcherRule.testDispatcher, + messagingDbDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun stubObserverRegistration( + expectedUri: Uri, + registeredObserver: CapturingSlot, + ) { + every { + contentResolver.registerContentObserver( + expectedUri, + true, + capture(registeredObserver), + ) + } just runs + every { + contentResolver.unregisterContentObserver(any()) + } just runs + } + + private fun createDraftAudioMessageData(): MessageData { + return MessageData.createDraftMmsMessage( + CONVERSATION_ID, + "self-1", + "", + "", + ).apply { + addPart( + MessagePartData.createMediaMessagePart( + "audio/3gpp", + Uri.parse("content://media/audio/1"), + 0, + 0, + ), + ) + } + } + + private fun createDraftImageMessageData(): MessageData { + return MessageData.createDraftMmsMessage( + CONVERSATION_ID, + "self-1", + "", + "", + ).apply { + addPart( + MessagePartData.createMediaMessagePart( + "image/jpeg", + Uri.parse("content://media/image/1"), + 640, + 480, + ), + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationParticipantsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationParticipantsRepositoryImplTest.kt new file mode 100644 index 000000000..f096ce39b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationParticipantsRepositoryImplTest.kt @@ -0,0 +1,288 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.TestParticipantRow +import com.android.messaging.testutil.createParticipantsCursor +import com.android.messaging.testutil.participantRow +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationParticipantsRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var contentResolver: ContentResolver + + @Before + fun setUp() { + contentResolver = mockk() + } + + @Test + fun getParticipants_registersAndUnregistersObserverForCollection() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationParticipantsUri( + CONVERSATION_ID, + ) + val repository = createRepository() + + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + every { + contentResolver.query( + expectedUri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + } returns createParticipantsCursor() + + repository.getParticipants(conversationId = CONVERSATION_ID).test { + assertEquals(emptyList(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + contentResolver.registerContentObserver( + expectedUri, + true, + registeredObserver.captured, + ) + } + verify(exactly = 1) { + contentResolver.unregisterContentObserver(registeredObserver.captured) + } + } + } + + @Test + fun getParticipants_mapsRowsAndFiltersSelfBlankAndDuplicateDestinations() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationParticipantsUri( + CONVERSATION_ID, + ) + val repository = createRepository() + + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + every { + contentResolver.query( + expectedUri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + } returns createParticipantsCursor( + recipientParticipantRow( + participantId = "self-1", + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + sendDestination = "+1 555 0000", + displayDestination = "+1 555 0000", + fullName = "Self", + ), + recipientParticipantRow( + participantId = "1", + sendDestination = "+1 555 0100", + displayDestination = "+1 555 0100", + fullName = "Ada", + profilePhotoUri = "content://photos/1", + ), + recipientParticipantRow( + participantId = "2", + sendDestination = " ", + displayDestination = " ", + fullName = "Ignored", + ), + recipientParticipantRow( + participantId = "3", + sendDestination = "+1 555 0100", + displayDestination = "+1 555 0100", + fullName = "Ada Duplicate", + ), + recipientParticipantRow( + participantId = "4", + sendDestination = "+1 555 0101", + displayDestination = "Bob", + fullName = "Bob", + ), + ) + + repository.getParticipants(conversationId = CONVERSATION_ID).test { + assertEquals( + listOf( + ConversationRecipient( + id = "1", + displayName = "Ada", + destination = "+1 555 0100", + photoUri = "content://photos/1", + secondaryText = "+1 555 0100", + ), + ConversationRecipient( + id = "4", + displayName = "Bob", + destination = "+1 555 0101", + secondaryText = null, + ), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun getParticipants_requeriesWhenConversationParticipantsChange() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.buildConversationParticipantsUri( + CONVERSATION_ID, + ) + val repository = createRepository() + var currentCursor = createParticipantsCursor( + recipientParticipantRow( + participantId = "1", + sendDestination = "+1 555 0100", + displayDestination = "+1 555 0100", + fullName = "Ada", + ), + ) + + stubObserverRegistration( + expectedUri = expectedUri, + registeredObserver = registeredObserver, + ) + every { + contentResolver.query( + expectedUri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + } answers { + currentCursor + } + + repository.getParticipants(conversationId = CONVERSATION_ID).test { + assertEquals( + listOf( + ConversationRecipient( + id = "1", + displayName = "Ada", + destination = "+1 555 0100", + secondaryText = "+1 555 0100", + ), + ), + awaitItem(), + ) + + currentCursor = createParticipantsCursor( + recipientParticipantRow( + participantId = "2", + sendDestination = "+1 555 0101", + displayDestination = "+1 555 0101", + fullName = "Bob", + ), + ) + registeredObserver.captured.onChange(false) + + assertEquals( + listOf( + ConversationRecipient( + id = "2", + displayName = "Bob", + destination = "+1 555 0101", + secondaryText = "+1 555 0101", + ), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + private fun createRepository(): ConversationParticipantsRepositoryImpl { + return ConversationParticipantsRepositoryImpl( + contentResolver = contentResolver, + defaultDispatcher = mainDispatcherRule.testDispatcher, + messagingDbDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun stubObserverRegistration( + expectedUri: Uri, + registeredObserver: CapturingSlot, + ) { + every { + contentResolver.registerContentObserver( + expectedUri, + true, + capture(registeredObserver), + ) + } just runs + every { contentResolver.unregisterContentObserver(any()) } just runs + } + + private fun recipientParticipantRow( + participantId: String, + sendDestination: String, + displayDestination: String, + fullName: String, + subId: Int = ParticipantData.OTHER_THAN_SELF_SUB_ID, + profilePhotoUri: String? = null, + ): TestParticipantRow { + return participantRow( + participantId = participantId, + subId = subId, + slotId = ParticipantData.INVALID_SLOT_ID, + normalizedDestination = sendDestination.trim(), + sendDestination = sendDestination, + displayDestination = displayDestination, + fullName = fullName, + profilePhotoUri = profilePhotoUri, + contactId = 1L, + lookupKey = "lookup-$participantId", + contactDestination = sendDestination, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt new file mode 100644 index 000000000..fab067c0e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt @@ -0,0 +1,72 @@ +package com.android.messaging.data.conversation.repository.conversations + +import android.content.ContentResolver +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.testutil.MainDispatcherRule +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationsRepositoryTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected lateinit var contentResolver: ContentResolver + + @Before + fun setUp() { + contentResolver = mockk() + } + + protected fun createRepository(): ConversationsRepositoryImpl { + return ConversationsRepositoryImpl( + contentResolver = contentResolver, + defaultDispatcher = mainDispatcherRule.testDispatcher, + messagingDbDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + protected fun stubObserverRegistration( + registeredObservers: MutableList, + expectedUri: Uri, + ) { + every { + contentResolver.registerContentObserver( + expectedUri, + true, + any(), + ) + } answers { + registeredObservers.add(thirdArg()) + } + every { contentResolver.unregisterContentObserver(any()) } just runs + } + + protected fun stubQuery( + expectedUri: Uri, + capturedProjections: MutableList?>, + result: Cursor?, + ) { + every { + contentResolver.query( + expectedUri, + any(), + null, + null, + null, + ) + } answers { + capturedProjections.add(secondArg?>()) + result + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt new file mode 100644 index 000000000..334a6aa73 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt @@ -0,0 +1,126 @@ +package com.android.messaging.data.conversation.repository.conversations + +import com.android.messaging.datamodel.action.DeleteConversationAction +import com.android.messaging.datamodel.action.DeleteMessageAction +import com.android.messaging.datamodel.action.RedownloadMmsAction +import com.android.messaging.datamodel.action.ResendMessageAction +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction +import io.mockk.every +import io.mockk.just +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationsRepositoryActionsTest : BaseConversationsRepositoryTest() { + + @Before + fun setUpActions() { + mockkStatic(DeleteMessageAction::class) + mockkStatic(RedownloadMmsAction::class) + mockkStatic(ResendMessageAction::class) + mockkStatic(UpdateConversationArchiveStatusAction::class) + mockkStatic(DeleteConversationAction::class) + every { DeleteMessageAction.deleteMessage(any()) } just runs + every { RedownloadMmsAction.redownloadMessage(any()) } just runs + every { ResendMessageAction.resendMessage(any()) } just runs + every { UpdateConversationArchiveStatusAction.archiveConversation(any()) } just runs + every { UpdateConversationArchiveStatusAction.unarchiveConversation(any()) } just runs + every { DeleteConversationAction.deleteConversation(any(), any()) } just runs + } + + @After + fun tearDownActions() { + unmockkAll() + } + + @Test + fun deleteMessages_skipsBlankIdsAndDelegatesNonBlankIds() { + createRepository().deleteMessages( + messageIds = listOf("message-1", "", " ", "message-2"), + ) + + verify(exactly = 1) { + DeleteMessageAction.deleteMessage("message-1") + } + verify(exactly = 1) { + DeleteMessageAction.deleteMessage("message-2") + } + verify(exactly = 0) { + DeleteMessageAction.deleteMessage("") + } + verify(exactly = 0) { + DeleteMessageAction.deleteMessage(" ") + } + } + + @Test + fun messageActions_skipBlankIdsAndDelegateNonBlankIds() { + val repository = createRepository() + + repository.downloadMessage(messageId = "") + repository.downloadMessage(messageId = "message-download") + repository.resendMessage(messageId = " ") + repository.resendMessage(messageId = "message-resend") + + verify(exactly = 0) { + RedownloadMmsAction.redownloadMessage("") + } + verify(exactly = 1) { + RedownloadMmsAction.redownloadMessage("message-download") + } + verify(exactly = 0) { + ResendMessageAction.resendMessage(" ") + } + verify(exactly = 1) { + ResendMessageAction.resendMessage("message-resend") + } + } + + @Test + fun archiveActions_skipBlankIdsAndDelegateNonBlankIds() { + val repository = createRepository() + + repository.archiveConversation(conversationId = "") + repository.archiveConversation(conversationId = "conversation-archive") + repository.unarchiveConversation(conversationId = " ") + repository.unarchiveConversation(conversationId = "conversation-unarchive") + + verify(exactly = 0) { + UpdateConversationArchiveStatusAction.archiveConversation("") + } + verify(exactly = 1) { + UpdateConversationArchiveStatusAction.archiveConversation("conversation-archive") + } + verify(exactly = 0) { + UpdateConversationArchiveStatusAction.unarchiveConversation(" ") + } + verify(exactly = 1) { + UpdateConversationArchiveStatusAction.unarchiveConversation("conversation-unarchive") + } + } + + @Test + fun deleteConversation_skipsBlankIdAndDelegatesNonBlankIdWithCutoff() { + val repository = createRepository() + + repository.deleteConversation(conversationId = "", cutoffTimestamp = 123L) + repository.deleteConversation( + conversationId = "conversation-delete", + cutoffTimestamp = 456L, + ) + + verify(exactly = 0) { + DeleteConversationAction.deleteConversation("", 123L) + } + verify(exactly = 1) { + DeleteConversationAction.deleteConversation("conversation-delete", 456L) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryDirectLookupTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryDirectLookupTest.kt new file mode 100644 index 000000000..4270f2395 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryDirectLookupTest.kt @@ -0,0 +1,389 @@ +package com.android.messaging.data.conversation.repository.conversations + +import android.database.Cursor +import android.database.MatrixCursor +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.createParticipantsCursor +import com.android.messaging.testutil.participantRow +import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationsRepositoryDirectLookupTest : BaseConversationsRepositoryTest() { + + @Test + fun getConversationSendData_returnsNullForBlankConversationIdWithoutQuerying() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val result = createRepository().getConversationSendData( + conversationId = " ", + requestedSelfParticipantId = "self-1", + ) + + assertNull(result) + verify(exactly = 0) { + contentResolver.query(any(), any(), any(), any(), any()) + } + } + } + + @Test + fun getConversationSendData_returnsMetadataParticipantsAndRequestedSelfParticipant() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val metadataUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val participantsUri = MessagingContentProvider + .buildConversationParticipantsUri(CONVERSATION_ID) + val participantSelectionArgsSlot = slot>() + every { + contentResolver.query( + metadataUri, + ConversationListItemData.PROJECTION, + null, + null, + null, + ) + } returns createConversationMetadataCursor( + conversationName = "Project", + selfParticipantId = "metadata-self", + participantCount = 2, + ) + every { + contentResolver.query( + participantsUri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "requested-self", + subId = 7, + slotId = 0, + subscriptionName = "Carrier", + ), + participantRow( + participantId = "other", + subId = ParticipantData.OTHER_THAN_SELF_SUB_ID, + slotId = ParticipantData.INVALID_SLOT_ID, + subscriptionName = "", + ), + ) + every { + contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + capture(participantSelectionArgsSlot), + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "requested-self", + subId = 7, + slotId = 0, + subscriptionName = "Carrier", + ), + ) + + val result = createRepository().getConversationSendData( + conversationId = CONVERSATION_ID, + requestedSelfParticipantId = "requested-self", + ) + + assertEquals("Project", result?.metadata?.conversationName) + assertEquals("metadata-self", result?.metadata?.selfParticipantId) + assertTrue(requireNotNull(result).participants.isLoaded) + assertEquals("requested-self", result.selfParticipant?.id) + assertEquals(listOf("requested-self"), participantSelectionArgsSlot.captured.toList()) + } + } + + @Test + fun getConversationSendData_usesMetadataSelfParticipantWhenRequestIsBlank() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val metadataUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val participantsUri = MessagingContentProvider + .buildConversationParticipantsUri(CONVERSATION_ID) + val participantSelectionArgsSlot = slot>() + every { + contentResolver.query( + metadataUri, + ConversationListItemData.PROJECTION, + null, + null, + null, + ) + } returns createConversationMetadataCursor( + conversationName = "Project", + selfParticipantId = "metadata-self", + participantCount = 1, + ) + every { + contentResolver.query( + participantsUri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + } returns createParticipantsCursor() + every { + contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + capture(participantSelectionArgsSlot), + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "metadata-self", + subId = 5, + slotId = 0, + subscriptionName = "Carrier", + ), + ) + + val result = createRepository().getConversationSendData( + conversationId = CONVERSATION_ID, + requestedSelfParticipantId = "", + ) + + assertEquals("metadata-self", result?.selfParticipant?.id) + assertEquals(listOf("metadata-self"), participantSelectionArgsSlot.captured.toList()) + } + } + + @Test + fun getMessageDetailsData_returnsMessageParticipantsAndSelfParticipant() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val messagesUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + val participantsUri = MessagingContentProvider + .buildConversationParticipantsUri(CONVERSATION_ID) + every { + contentResolver.query( + messagesUri, + ConversationMessageData.getProjection(), + null, + null, + null, + ) + } returns createConversationMessagesCursor( + messageRow( + messageId = "message-1", + participantId = "other", + selfParticipantId = "self-1", + text = "Hello", + ), + ) + every { + contentResolver.query( + participantsUri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "self-1", + subId = 1, + slotId = 0, + subscriptionName = "Carrier", + ), + participantRow( + participantId = "other", + subId = ParticipantData.OTHER_THAN_SELF_SUB_ID, + slotId = ParticipantData.INVALID_SLOT_ID, + subscriptionName = "", + ), + ) + every { + contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + arrayOf("self-1"), + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "self-1", + subId = 1, + slotId = 0, + subscriptionName = "Carrier", + ), + ) + + val result = createRepository().getMessageDetailsData( + conversationId = CONVERSATION_ID, + messageId = "message-1", + ) + + assertEquals("message-1", result?.message?.messageId) + assertEquals("Hello", result?.message?.text) + assertTrue(requireNotNull(result).participants.isLoaded) + assertEquals("self-1", result.selfParticipant?.id) + } + } + + @Test + fun getMessageDetailsData_returnsNullWhenMessageCannotBeResolved() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val result = createRepository().getMessageDetailsData( + conversationId = "", + messageId = "message-1", + ) + + assertNull(result) + verify(exactly = 0) { + contentResolver.query(any(), any(), any(), any(), any()) + } + } + } + + @Suppress("SameParameterValue") + private fun createConversationMetadataCursor( + conversationName: String, + selfParticipantId: String, + participantCount: Int, + ): Cursor { + val cursor = MatrixCursor(ConversationListItemData.PROJECTION) + val row: Map = mapOf( + ConversationColumns._ID to CONVERSATION_ID, + ConversationColumns.NAME to conversationName, + ConversationColumns.ICON to "", + ConversationColumns.SNIPPET_TEXT to "", + ConversationColumns.SORT_TIMESTAMP to 10L, + MessageColumns.READ to 1, + ConversationColumns.PREVIEW_URI to "", + ConversationColumns.PREVIEW_CONTENT_TYPE to "", + ConversationColumns.PARTICIPANT_CONTACT_ID to -1L, + ConversationColumns.PARTICIPANT_LOOKUP_KEY to "", + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION to "", + ConversationColumns.PARTICIPANT_COUNT to participantCount, + ConversationColumns.CURRENT_SELF_ID to selfParticipantId, + ConversationColumns.NOTIFICATION_ENABLED to 1, + ConversationColumns.NOTIFICATION_SOUND_URI to "", + ConversationColumns.NOTIFICATION_VIBRATION to 0, + ConversationColumns.INCLUDE_EMAIL_ADDRESS to 0, + MessageColumns.STATUS to MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ConversationColumns.SHOW_DRAFT to 0, + ConversationColumns.DRAFT_PREVIEW_URI to "", + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE to "", + ConversationColumns.DRAFT_SNIPPET_TEXT to "", + ConversationColumns.ARCHIVE_STATUS to 0, + MessageColumns._ID to "message-1", + ConversationColumns.SUBJECT_TEXT to "", + ConversationColumns.DRAFT_SUBJECT_TEXT to "", + MessageColumns.RAW_TELEPHONY_STATUS to 0, + "snippet_sender_first_name" to "", + "snippet_sender_display_destination" to "", + ConversationColumns.IS_ENTERPRISE to 0, + ) + cursor.addRow( + ConversationListItemData.PROJECTION.map { columnName -> + row[columnName] + }.toTypedArray(), + ) + return cursor + } + + private fun createConversationMessagesCursor(vararg rows: TestMessageRow): Cursor { + val cursor = MatrixCursor(ConversationMessageData.getProjection()) + rows.forEach { row -> + cursor.addRow( + ConversationMessageData.getProjection().map { columnName -> + row.toColumnValues()[columnName] + }.toTypedArray(), + ) + } + return cursor + } + + @Suppress("SameParameterValue") + private fun messageRow( + messageId: String, + participantId: String, + selfParticipantId: String, + text: String, + ): TestMessageRow { + return TestMessageRow( + messageId = messageId, + participantId = participantId, + selfParticipantId = selfParticipantId, + text = text, + ) + } + + private data class TestMessageRow( + val messageId: String, + val participantId: String, + val selfParticipantId: String, + val text: String, + ) { + fun toColumnValues(): Map { + return mapOf( + MessageColumns._ID to messageId, + MessageColumns.CONVERSATION_ID to CONVERSATION_ID, + MessageColumns.SENDER_PARTICIPANT_ID to participantId, + ConversationColumns.ICON to "", + "parts_ids" to "part-$messageId", + "parts_content_types" to "text/plain", + "parts_content_uris" to "", + "parts_widths" to "0", + "parts_heights" to "0", + "parts_texts" to text, + "parts_count" to 1, + MessageColumns.SENT_TIMESTAMP to 1_000L, + MessageColumns.RECEIVED_TIMESTAMP to 1_000L, + MessageColumns.SEEN to 1, + MessageColumns.READ to 1, + MessageColumns.PROTOCOL to MessageData.PROTOCOL_SMS, + MessageColumns.STATUS to MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + MessageColumns.SMS_MESSAGE_URI to "", + MessageColumns.SMS_PRIORITY to 0, + MessageColumns.SMS_MESSAGE_SIZE to 0, + MessageColumns.MMS_SUBJECT to "", + MessageColumns.MMS_EXPIRY to 0L, + MessageColumns.RAW_TELEPHONY_STATUS to 0, + MessageColumns.SELF_PARTICIPANT_ID to selfParticipantId, + ParticipantColumns.FULL_NAME to "", + ParticipantColumns.FIRST_NAME to "", + ParticipantColumns.DISPLAY_DESTINATION to "", + ParticipantColumns.NORMALIZED_DESTINATION to "", + ParticipantColumns.PROFILE_PHOTO_URI to "", + ParticipantColumns.CONTACT_ID to 0L, + ParticipantColumns.LOOKUP_KEY to "", + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt new file mode 100644 index 000000000..ad4a12674 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt @@ -0,0 +1,624 @@ +package com.android.messaging.data.conversation.repository.conversations + +import android.database.ContentObserver +import android.database.Cursor +import app.cash.turbine.test +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationsRepositoryMessagesTest : BaseConversationsRepositoryTest() { + + @Test + fun getConversationMessages_registersAndUnregistersObserverForCollection() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMessagesCursor(rows = emptyList()), + ) + + repository.getConversationMessages(conversationId = CONVERSATION_ID).test { + assertTrue(awaitItem().isEmpty()) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + contentResolver.registerContentObserver( + expectedUri, + true, + registeredObservers.single(), + ) + } + verify(exactly = 1) { + contentResolver.unregisterContentObserver(registeredObservers.single()) + } + assertEquals( + ConversationMessageData.getProjection().toList(), + capturedProjections.single()?.toList(), + ) + } + } + + @Test + fun getConversationMessages_emitsMessagesInUiOrderWithLegacyClusteringRules() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + val messagesInUiOrder = listOf( + messageRow( + messageId = "pair-1", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 0L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Pair top", + ), + messageRow( + messageId = "pair-2", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 30_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Pair bottom", + ), + messageRow( + messageId = "gap-break", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 91_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Gap break", + ), + messageRow( + messageId = "sender-break", + participantId = "participant-b", + selfParticipantId = "self-1", + receivedTimestamp = 100_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Different sender", + ), + messageRow( + messageId = "outgoing-1", + participantId = "self-sender", + selfParticipantId = "self-1", + receivedTimestamp = 130_000L, + status = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, + text = "Outgoing top", + ), + messageRow( + messageId = "outgoing-2", + participantId = "self-sender", + selfParticipantId = "self-1", + receivedTimestamp = 150_000L, + status = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, + text = "Outgoing bottom", + ), + messageRow( + messageId = "self-break", + participantId = "self-sender", + selfParticipantId = "self-2", + receivedTimestamp = 170_000L, + status = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, + text = "Different self participant", + ), + ) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMessagesCursor(rows = messagesInUiOrder.asReversed()), + ) + + repository.getConversationMessages(conversationId = CONVERSATION_ID).test { + val messages = awaitItem() + + assertEquals( + messagesInUiOrder.map { it.messageId }, + messages.map { it.messageId }, + ) + assertEquals( + messagesInUiOrder.map { it.text }, + messages.map { it.text }, + ) + + assertClusterState( + message = messages[0], + canClusterWithPrevious = false, + canClusterWithNext = true, + ) + assertClusterState( + message = messages[1], + canClusterWithPrevious = true, + canClusterWithNext = false, + ) + assertClusterState( + message = messages[2], + canClusterWithPrevious = false, + canClusterWithNext = false, + ) + assertClusterState( + message = messages[3], + canClusterWithPrevious = false, + canClusterWithNext = false, + ) + assertClusterState( + message = messages[4], + canClusterWithPrevious = false, + canClusterWithNext = true, + ) + assertClusterState( + message = messages[5], + canClusterWithPrevious = true, + canClusterWithNext = false, + ) + assertClusterState( + message = messages[6], + canClusterWithPrevious = false, + canClusterWithNext = false, + ) + + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { contentResolver.query(expectedUri, any(), null, null, null) } + assertEquals( + ConversationMessageData.getProjection().toList(), + capturedProjections.single()?.toList(), + ) + } + } + + @Test + fun getConversationMessages_requeriesWhenObserverChanges() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + val firstMessage = messageRow( + messageId = "first", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 1_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "First", + ) + val secondMessage = messageRow( + messageId = "second", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 2_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Second", + ) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + every { + contentResolver.query( + expectedUri, + any(), + null, + null, + null, + ) + } answers { + capturedProjections.add(secondArg?>()) + when (capturedProjections.size) { + 1 -> createConversationMessagesCursor(rows = listOf(firstMessage)) + 2 -> createConversationMessagesCursor( + rows = listOf(secondMessage, firstMessage), + ) + else -> error("Unexpected query call ${capturedProjections.size}") + } + } + + repository.getConversationMessages(conversationId = CONVERSATION_ID).test { + assertEquals(listOf("first"), awaitItem().map { it.messageId }) + + registeredObservers.single().onChange(false) + + assertEquals( + listOf("first", "second"), + awaitItem().map { it.messageId }, + ) + + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 2) { + contentResolver.query( + expectedUri, + any(), + null, + null, + null, + ) + } + assertEquals( + listOf( + ConversationMessageData.getProjection().toList(), + ConversationMessageData.getProjection().toList(), + ), + capturedProjections.map { it?.toList() }, + ) + } + } + + @Test + fun getConversationMessages_singleMessageHasNoClustering() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + val singleMessage = messageRow( + messageId = "only", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 1_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Only message", + ) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMessagesCursor(rows = listOf(singleMessage)), + ) + + repository.getConversationMessages(conversationId = CONVERSATION_ID).test { + val messages = awaitItem() + + assertEquals(1, messages.size) + assertEquals("only", messages[0].messageId) + assertClusterState( + message = messages[0], + canClusterWithPrevious = false, + canClusterWithNext = false, + ) + + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun getConversationMessages_clustersAtExactlyOneMinuteButNotOneMillisOver() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + val messagesInUiOrder = listOf( + messageRow( + messageId = "msg-a", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 0L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "First", + ), + messageRow( + messageId = "msg-b", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 60_000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "Exactly one minute later", + ), + messageRow( + messageId = "msg-c", + participantId = "participant-a", + selfParticipantId = "self-1", + receivedTimestamp = 120_001L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + text = "One millisecond over one minute from msg-b", + ), + ) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMessagesCursor(rows = messagesInUiOrder.asReversed()), + ) + + repository.getConversationMessages(conversationId = CONVERSATION_ID).test { + val messages = awaitItem() + + assertEquals(3, messages.size) + + assertClusterState( + message = messages[0], + canClusterWithPrevious = false, + canClusterWithNext = true, + ) + assertClusterState( + message = messages[1], + canClusterWithPrevious = true, + canClusterWithNext = false, + ) + assertClusterState( + message = messages[2], + canClusterWithPrevious = false, + canClusterWithNext = false, + ) + + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun getConversationMessages_returnsEmptyListWhenQueryReturnsNull() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMessagesUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = null, + ) + + repository.getConversationMessages(conversationId = CONVERSATION_ID).test { + assertTrue(awaitItem().isEmpty()) + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + ConversationMessageData.getProjection().toList(), + capturedProjections.single()?.toList(), + ) + } + } + + private fun createConversationMessagesCursor(rows: List): Cursor { + val projection = ConversationMessageData.getProjection() + val rowsByColumn = rows.map { it.toColumnValues() } + var position = -1 + val cursor = mockk() + + fun currentRow(): Map { + check(position in rowsByColumn.indices) { + "Cursor position $position is out of bounds for ${rowsByColumn.size} rows" + } + return rowsByColumn[position] + } + + fun moveToPosition(positionToMove: Int): Boolean { + return when { + rowsByColumn.isEmpty() -> { + position = -1 + false + } + + positionToMove < 0 -> { + position = -1 + false + } + + positionToMove >= rowsByColumn.size -> { + position = rowsByColumn.size + false + } + + else -> { + position = positionToMove + true + } + } + } + + every { cursor.count } returns rowsByColumn.size + every { cursor.close() } just runs + every { cursor.position } answers { position } + every { cursor.isBeforeFirst } answers { + rowsByColumn.isNotEmpty() && position < 0 + } + every { cursor.isAfterLast } answers { + rowsByColumn.isNotEmpty() && position >= rowsByColumn.size + } + every { cursor.isFirst } answers { + rowsByColumn.isNotEmpty() && position == 0 + } + every { cursor.isLast } answers { + rowsByColumn.isNotEmpty() && position == rowsByColumn.lastIndex + } + every { cursor.moveToPosition(any()) } answers { + moveToPosition(positionToMove = firstArg()) + } + every { cursor.move(any()) } answers { + moveToPosition(positionToMove = position + firstArg()) + } + every { cursor.moveToFirst() } answers { + moveToPosition(positionToMove = 0) + } + every { cursor.moveToLast() } answers { + moveToPosition(positionToMove = rowsByColumn.lastIndex) + } + every { cursor.moveToNext() } answers { + val nextPosition = if (position < 0) 0 else position + 1 + moveToPosition(positionToMove = nextPosition) + } + every { cursor.moveToPrevious() } answers { + val previousPosition = if (position > rowsByColumn.lastIndex) { + rowsByColumn.lastIndex + } else { + position - 1 + } + moveToPosition(positionToMove = previousPosition) + } + every { cursor.getString(any()) } answers { + currentRow()[projection[firstArg()]]?.toString() + } + every { cursor.getInt(any()) } answers { + currentRow()[projection[firstArg()]].toIntValue() + } + every { cursor.getLong(any()) } answers { + currentRow()[projection[firstArg()]].toLongValue() + } + + return cursor + } + + private fun assertClusterState( + message: ConversationMessageData, + canClusterWithPrevious: Boolean, + canClusterWithNext: Boolean, + ) { + assertEquals(canClusterWithPrevious, message.canClusterWithPreviousMessage) + assertEquals(canClusterWithNext, message.canClusterWithNextMessage) + } + + private fun messageRow( + messageId: String, + participantId: String, + selfParticipantId: String, + receivedTimestamp: Long, + status: Int, + text: String, + ): TestMessageRow { + return TestMessageRow( + messageId = messageId, + participantId = participantId, + selfParticipantId = selfParticipantId, + receivedTimestamp = receivedTimestamp, + status = status, + text = text, + ) + } + + private data class TestMessageRow( + val messageId: String, + val participantId: String, + val selfParticipantId: String, + val receivedTimestamp: Long, + val status: Int, + val text: String, + ) { + fun toColumnValues(): Map { + return mapOf( + MessageColumns._ID to messageId, + MessageColumns.CONVERSATION_ID to CONVERSATION_ID, + MessageColumns.SENDER_PARTICIPANT_ID to participantId, + ConversationColumns.ICON to "", + "parts_ids" to "part-$messageId", + "parts_content_types" to TEXT_CONTENT_TYPE, + "parts_content_uris" to "", + "parts_widths" to "0", + "parts_heights" to "0", + "parts_texts" to text, + "parts_count" to 1, + MessageColumns.SENT_TIMESTAMP to receivedTimestamp, + MessageColumns.RECEIVED_TIMESTAMP to receivedTimestamp, + MessageColumns.SEEN to 1, + MessageColumns.READ to 1, + MessageColumns.PROTOCOL to MessageData.PROTOCOL_SMS, + MessageColumns.STATUS to status, + MessageColumns.SMS_MESSAGE_URI to "", + MessageColumns.SMS_PRIORITY to 0, + MessageColumns.SMS_MESSAGE_SIZE to 0, + MessageColumns.MMS_SUBJECT to "", + MessageColumns.MMS_EXPIRY to 0L, + MessageColumns.RAW_TELEPHONY_STATUS to 0, + MessageColumns.SELF_PARTICIPANT_ID to selfParticipantId, + ParticipantColumns.FULL_NAME to "Sender $participantId", + ParticipantColumns.FIRST_NAME to "Sender", + ParticipantColumns.DISPLAY_DESTINATION to "+1555$messageId", + ParticipantColumns.NORMALIZED_DESTINATION to "+1555$messageId", + ParticipantColumns.PROFILE_PHOTO_URI to "", + ParticipantColumns.CONTACT_ID to 0L, + ParticipantColumns.LOOKUP_KEY to "", + ) + } + } + + private fun Any?.toIntValue(): Int { + return when (this) { + null -> 0 + is Int -> this + is Long -> this.toInt() + is Boolean -> if (this) 1 else 0 + is String -> this.toInt() + else -> error("Unsupported int value: $this") + } + } + + private fun Any?.toLongValue(): Long { + return when (this) { + null -> 0L + is Long -> this + is Int -> this.toLong() + is Boolean -> if (this) 1L else 0L + is String -> this.toLong() + else -> error("Unsupported long value: $this") + } + } + + private companion object { + private const val TEXT_CONTENT_TYPE = "text/plain" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt new file mode 100644 index 000000000..3bdb38649 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt @@ -0,0 +1,351 @@ +package com.android.messaging.data.conversation.repository.conversations + +import android.database.ContentObserver +import android.database.Cursor +import android.database.MatrixCursor +import app.cash.turbine.test +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.createParticipantsCursor +import com.android.messaging.testutil.participantRow +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationsRepositoryMetadataTest : BaseConversationsRepositoryTest() { + + @Test + fun getConversationMetadata_registersAndUnregistersObserverForCollection() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMetadataCursor( + row = conversationMetadataRow( + conversationName = "Weekend plan", + selfParticipantId = "self-1", + participantCount = 3, + ), + ), + ) + + repository.getConversationMetadata(conversationId = CONVERSATION_ID).test { + assertEquals("Weekend plan", awaitItem()?.conversationName) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + contentResolver.registerContentObserver( + expectedUri, + true, + registeredObservers.single(), + ) + } + verify(exactly = 1) { + contentResolver.unregisterContentObserver(registeredObservers.single()) + } + assertEquals( + ConversationListItemData.PROJECTION.toList(), + capturedProjections.single()?.toList(), + ) + } + } + + @Test + fun getConversationMetadata_emitsMappedMetadata() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMetadataCursor( + row = conversationMetadataRow( + conversationName = "Carol, Dave, Erin", + selfParticipantId = "self-2", + participantCount = 3, + ), + ), + ) + + repository.getConversationMetadata(conversationId = CONVERSATION_ID).test { + val metadata = awaitItem() + + assertEquals("Carol, Dave, Erin", metadata?.conversationName) + assertEquals("self-2", metadata?.selfParticipantId) + assertEquals(true, metadata?.isGroupConversation) + assertEquals(3, metadata?.participantCount) + assertEquals(false, metadata?.isArchived) + assertEquals(null, metadata?.otherParticipantContactLookupKey) + + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + ConversationListItemData.PROJECTION.toList(), + capturedProjections.single()?.toList(), + ) + } + } + + @Test + fun getConversationMetadata_exposesArchiveStatusAndLookupKey() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMetadataCursor( + row = conversationMetadataRow( + conversationName = "Carol", + selfParticipantId = "self-2", + participantCount = 2, + isArchived = true, + otherParticipantLookupKey = "lookup-key-carol", + ), + ), + ) + + repository.getConversationMetadata(conversationId = CONVERSATION_ID).test { + val metadata = awaitItem() + + assertEquals(true, metadata?.isArchived) + assertEquals("lookup-key-carol", metadata?.otherParticipantContactLookupKey) + + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun getConversationMetadata_oneOnOneConversation_usesParticipantDetailsForSubtitleAndAvatar() { + runTest(context = mainDispatcherRule.testDispatcher) { + val registeredObservers = mutableListOf() + val metadataProjections = mutableListOf?>() + val participantsProjections = mutableListOf?>() + val repository = createRepository() + val metadataUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + val participantsUri = MessagingContentProvider + .buildConversationParticipantsUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = metadataUri, + ) + stubQuery( + expectedUri = metadataUri, + capturedProjections = metadataProjections, + result = createConversationMetadataCursor( + row = conversationMetadataRow( + conversationName = "Carol", + selfParticipantId = "self-2", + participantCount = 1, + otherParticipantLookupKey = "legacy-lookup-key", + otherParticipantNormalizedDestination = TEST_CALL_ACTION_PHONE_NUMBER, + ), + ), + ) + stubQuery( + expectedUri = participantsUri, + capturedProjections = participantsProjections, + result = createParticipantsCursor( + participantRow( + participantId = "self-2", + subId = 1, + displayDestination = "", + normalizedDestination = "", + profilePhotoUri = "", + lookupKey = "", + contactId = 1L, + fullName = "Name self-2", + firstName = "Name", + ), + participantRow( + participantId = "participant-1", + subId = ParticipantData.OTHER_THAN_SELF_SUB_ID, + displayDestination = "(555) 123-4567", + normalizedDestination = TEST_CALL_ACTION_PHONE_NUMBER, + profilePhotoUri = "content://contacts/people/1/photo", + lookupKey = "lookup-key-carol", + contactId = 1L, + fullName = "Name participant-1", + firstName = "Name", + ), + ), + ) + + repository.getConversationMetadata(conversationId = CONVERSATION_ID).test { + val metadata = awaitItem() + + assertEquals("(555) 123-4567", metadata?.otherParticipantDisplayDestination) + assertEquals( + TEST_CALL_ACTION_PHONE_NUMBER, + metadata?.otherParticipantNormalizedDestination, + ) + assertEquals("lookup-key-carol", metadata?.otherParticipantContactLookupKey) + assertEquals( + "content://contacts/people/1/photo", + metadata?.otherParticipantPhotoUri, + ) + + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + ParticipantData.ParticipantsQuery.PROJECTION.toList(), + participantsProjections.single()?.toList(), + ) + } + } + + @Test + fun getConversationMetadata_returnsNullWhenCursorIsEmpty() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val registeredObservers = mutableListOf() + val capturedProjections = mutableListOf?>() + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri(CONVERSATION_ID) + + stubObserverRegistration( + registeredObservers = registeredObservers, + expectedUri = expectedUri, + ) + stubQuery( + expectedUri = expectedUri, + capturedProjections = capturedProjections, + result = createConversationMetadataCursor(row = null), + ) + + repository.getConversationMetadata(conversationId = CONVERSATION_ID).test { + assertEquals(null, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + ConversationListItemData.PROJECTION.toList(), + capturedProjections.single()?.toList(), + ) + } + } + + private fun createConversationMetadataCursor(row: TestConversationMetadataRow?): Cursor { + val cursor = MatrixCursor(ConversationListItemData.PROJECTION) + + if (row != null) { + cursor.addRow( + ConversationListItemData.PROJECTION.map { columnName -> + row.toColumnValues()[columnName] + }.toTypedArray(), + ) + } + + return cursor + } + + private fun conversationMetadataRow( + conversationName: String, + selfParticipantId: String, + participantCount: Int, + isArchived: Boolean = false, + otherParticipantLookupKey: String = "", + otherParticipantNormalizedDestination: String = "", + ): TestConversationMetadataRow { + return TestConversationMetadataRow( + conversationName = conversationName, + selfParticipantId = selfParticipantId, + participantCount = participantCount, + isArchived = isArchived, + otherParticipantLookupKey = otherParticipantLookupKey, + otherParticipantNormalizedDestination = otherParticipantNormalizedDestination, + ) + } + + private data class TestConversationMetadataRow( + val conversationName: String, + val selfParticipantId: String, + val participantCount: Int, + val isArchived: Boolean = false, + val otherParticipantLookupKey: String = "", + val otherParticipantNormalizedDestination: String = "", + ) { + fun toColumnValues(): Map { + return mapOf( + ConversationColumns._ID to CONVERSATION_ID, + ConversationColumns.NAME to conversationName, + ConversationColumns.ICON to "", + ConversationColumns.SNIPPET_TEXT to "", + ConversationColumns.SORT_TIMESTAMP to 0L, + MessageColumns.READ to 1, + ConversationColumns.PREVIEW_URI to "", + ConversationColumns.PREVIEW_CONTENT_TYPE to "", + ConversationColumns.PARTICIPANT_CONTACT_ID to -1L, + ConversationColumns.PARTICIPANT_LOOKUP_KEY to otherParticipantLookupKey, + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION to + otherParticipantNormalizedDestination, + ConversationColumns.PARTICIPANT_COUNT to participantCount, + ConversationColumns.CURRENT_SELF_ID to selfParticipantId, + ConversationColumns.NOTIFICATION_ENABLED to 1, + ConversationColumns.NOTIFICATION_SOUND_URI to "", + ConversationColumns.NOTIFICATION_VIBRATION to 0, + ConversationColumns.INCLUDE_EMAIL_ADDRESS to 0, + MessageColumns.STATUS to MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ConversationColumns.SHOW_DRAFT to 0, + ConversationColumns.DRAFT_PREVIEW_URI to "", + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE to "", + ConversationColumns.DRAFT_SNIPPET_TEXT to "", + ConversationColumns.ARCHIVE_STATUS to if (isArchived) 1 else 0, + MessageColumns._ID to "message-1", + ConversationColumns.SUBJECT_TEXT to "", + ConversationColumns.DRAFT_SUBJECT_TEXT to "", + MessageColumns.RAW_TELEPHONY_STATUS to 0, + "snippet_sender_first_name" to "", + "snippet_sender_display_destination" to "", + ConversationColumns.IS_ENTERPRISE to 0, + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt new file mode 100644 index 000000000..addf9e70e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt @@ -0,0 +1,78 @@ +package com.android.messaging.data.conversation.store + +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class ConversationDraftStoreTest { + + private val databaseWrapper = mockk() + private val dataModel = mockk() + + private val store = ConversationDraftStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(ConversationListItemData::class) + + every { DataModel.get() } returns dataModel + every { dataModel.database } returns databaseWrapper + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun getSelfParticipantId_whenConversationIsMissing_returnsNull() { + every { + ConversationListItemData.getExistingConversation(databaseWrapper, CONVERSATION_ID) + } returns null + + val selfParticipantId = store.getSelfParticipantId(conversationId = CONVERSATION_ID) + + assertNull(selfParticipantId) + } + + @Test + fun getSelfParticipantId_whenSelfIdIsBlank_returnsNull() { + val conversation = mockk() + every { conversation.selfId } returns " " + every { + ConversationListItemData.getExistingConversation(databaseWrapper, CONVERSATION_ID) + } returns conversation + + val selfParticipantId = store.getSelfParticipantId(conversationId = CONVERSATION_ID) + + assertNull(selfParticipantId) + } + + @Test + fun getSelfParticipantId_whenSelfIdIsPresent_returnsSelfId() { + val conversation = mockk() + every { conversation.selfId } returns SELF_PARTICIPANT_ID + every { + ConversationListItemData.getExistingConversation(databaseWrapper, CONVERSATION_ID) + } returns conversation + + val selfParticipantId = store.getSelfParticipantId(conversationId = CONVERSATION_ID) + + assertEquals(SELF_PARTICIPANT_ID, selfParticipantId) + } + + private companion object { + private const val SELF_PARTICIPANT_ID = "self-1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt new file mode 100644 index 000000000..48f985773 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt @@ -0,0 +1,230 @@ +package com.android.messaging.data.media.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.util.ContentType +import com.android.messaging.util.UriUtil +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationMediaRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun getRecentMedia_queriesMediaStoreWithExpectedProjectionAndArgs() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val contentResolver = mockk() + val capturedUri = slot() + val capturedProjection = slot>() + val capturedQueryArgs = slot() + + every { + contentResolver.query( + capture(capturedUri), + capture(capturedProjection), + capture(capturedQueryArgs), + null, + ) + } returns createCursor() + + val repository = createRepository(contentResolver = contentResolver) + + repository.getRecentMedia(limit = 37).single() + + verify(exactly = 1) { + contentResolver.query( + any(), + any>(), + any(), + null, + ) + } + + assertRecentMediaQuery( + uri = capturedUri.captured, + projection = capturedProjection.captured, + queryArgs = capturedQueryArgs.captured, + limit = 37, + ) + } + } + + @Test + fun getRecentMedia_mapsCursorRows() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentResolver = mockk() + stubQuery( + contentResolver = contentResolver, + result = createCursor(), + ) + val repository = createRepository(contentResolver = contentResolver) + + val items = repository.getRecentMedia(limit = 37).single() + + assertEquals( + listOf( + ConversationMediaItem( + mediaId = "10", + contentUri = UriUtil.getContentUriForMediaStoreId(10L).toString(), + contentType = ContentType.IMAGE_UNSPECIFIED, + width = null, + height = 720, + durationMillis = null, + ), + ConversationMediaItem( + mediaId = "11", + contentUri = UriUtil.getContentUriForMediaStoreId(11L).toString(), + contentType = ContentType.VIDEO_UNSPECIFIED, + width = 1920, + height = 1080, + durationMillis = 1234L, + ), + ), + items, + ) + } + } + + @Test + fun getRecentMedia_returnsEmptyListForNullCursor() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val contentResolver = mockk() + stubQuery( + contentResolver = contentResolver, + result = null, + ) + val repository = createRepository(contentResolver = contentResolver) + + val items = repository.getRecentMedia(limit = 7).single() + + assertEquals(emptyList(), items) + } + } + + private fun createRepository( + contentResolver: ContentResolver, + ): ConversationMediaRepositoryImpl { + return ConversationMediaRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun stubQuery( + contentResolver: ContentResolver, + result: Cursor?, + ) { + every { + contentResolver.query( + any(), + any>(), + any(), + null, + ) + } returns result + } + + @Suppress("SameParameterValue") + private fun assertRecentMediaQuery( + uri: Uri, + projection: Array, + queryArgs: Bundle, + limit: Int, + ) { + assertEquals( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + uri, + ) + assertEquals( + listOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.DATE_ADDED, + MediaStore.Files.FileColumns.WIDTH, + MediaStore.Files.FileColumns.HEIGHT, + MediaStore.Video.VideoColumns.DURATION, + ), + projection.toList(), + ) + assertEquals( + "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN " + + "(${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE}," + + "${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO})", + queryArgs.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertEquals( + listOf(MediaStore.Files.FileColumns.DATE_ADDED), + queryArgs.getStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS)?.toList(), + ) + assertEquals( + ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + queryArgs.getInt(ContentResolver.QUERY_ARG_SORT_DIRECTION), + ) + assertEquals( + limit, + queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT), + ) + } + + private fun createCursor(): Cursor { + return MatrixCursor( + arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.DATE_ADDED, + MediaStore.Files.FileColumns.WIDTH, + MediaStore.Files.FileColumns.HEIGHT, + MediaStore.Video.VideoColumns.DURATION, + ), + ).apply { + addRow( + arrayOf( + 10L, + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE, + "", + 1L, + 0, + 720, + 0L, + ), + ) + addRow( + arrayOf( + 11L, + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO, + "", + 2L, + 1920, + 1080, + 1234L, + ), + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/conversationattachments/ConversationAttachmentsRepositoryVideoMetadataTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/conversationattachments/ConversationAttachmentsRepositoryVideoMetadataTest.kt new file mode 100644 index 000000000..9862ae1be --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/conversationattachments/ConversationAttachmentsRepositoryVideoMetadataTest.kt @@ -0,0 +1,325 @@ +package com.android.messaging.data.media.repository.conversationattachments + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.res.AssetFileDescriptor +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.model.SaveAttachmentsResult +import com.android.messaging.data.media.repository.ConversationAttachmentsRepositoryImpl +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.testutil.MainDispatcherRule +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FileDescriptor +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationAttachmentsRepositoryVideoMetadataTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun createDraftAttachmentsFromPhotoPicker_resolvesVideoMetadata() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val sourceUri = Uri.parse("content://picker/video") + val scratchUri = Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/video") + val scratchBytes = ByteArrayOutputStream() + val contentResolver = createContentResolverForVideoPicker( + sourceUri = sourceUri, + contentType = "video/mp4", + scratchUri = scratchUri, + scratchSink = scratchBytes, + ) + val repository = createRepository(contentResolver = contentResolver) + + mockScratchUri(scratchUri = scratchUri, extension = "mp4") + mockVideoMetadata( + contentResolver = contentResolver, + scratchUri = scratchUri, + width = "1920", + height = "1080", + durationMillis = "3000", + ) + + repository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf(sourceUri.toString()), + ).test { + assertEquals( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = PhotoPickerDraftAttachment( + sourceContentUri = sourceUri.toString(), + draftAttachment = ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = scratchUri.toString(), + width = 1920, + height = 1080, + durationMillis = 3000L, + ), + ), + ), + awaitItem(), + ) + awaitComplete() + } + + assertArrayEquals(byteArrayOf(1, 2, 3), scratchBytes.toByteArray()) + verify(exactly = 1) { + anyConstructed().release() + } + } + } + + @Test + fun createDraftAttachmentsFromPhotoPicker_continuesAfterUnresolvableItem() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val sourceUri = Uri.parse("https://example.test/video.mp4") + val scratchUri = Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/video") + val contentResolver = createContentResolverForVideoPicker( + sourceUri = sourceUri, + contentType = null, + scratchUri = scratchUri, + ) + val repository = createRepository(contentResolver = contentResolver) + + mockScratchUri(scratchUri = scratchUri, extension = "mp4") + mockVideoMetadata( + contentResolver = contentResolver, + scratchUri = scratchUri, + width = "640", + height = "480", + durationMillis = "not-a-number", + ) + + repository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf("", sourceUri.toString()), + ).test { + assertEquals( + PhotoPickerDraftAttachmentResult.Failed(sourceContentUri = ""), + awaitItem(), + ) + assertEquals( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = PhotoPickerDraftAttachment( + sourceContentUri = sourceUri.toString(), + draftAttachment = ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = scratchUri.toString(), + width = 640, + height = 480, + durationMillis = null, + ), + ), + ), + awaitItem(), + ) + awaitComplete() + } + } + } + + @Test + fun createDraftAttachmentsFromPhotoPicker_returnsVideoAttachmentWhenMetadataReadFails() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val sourceUri = Uri.parse("content://picker/video") + val scratchUri = Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/video") + val contentResolver = createContentResolverForVideoPicker( + sourceUri = sourceUri, + contentType = "video/mp4", + scratchUri = scratchUri, + ) + val repository = createRepository(contentResolver = contentResolver) + + mockScratchUri(scratchUri = scratchUri, extension = "mp4") + mockkConstructor(MediaMetadataRetriever::class) + every { + contentResolver.openAssetFileDescriptor(scratchUri, "r") + } throws IllegalStateException("metadata unavailable") + every { + anyConstructed().release() + } just runs + + repository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf(sourceUri.toString()), + ).test { + assertEquals( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = PhotoPickerDraftAttachment( + sourceContentUri = sourceUri.toString(), + draftAttachment = ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = scratchUri.toString(), + width = null, + height = null, + durationMillis = null, + ), + ), + ), + awaitItem(), + ) + awaitComplete() + } + + verify(exactly = 1) { + anyConstructed().release() + } + } + } + + @Test + fun saveAttachmentsToMediaStore_savesUnknownContentToDownloadsAndCountsAsOther() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val pendingUri = Uri.parse("content://media/external/downloads/pending") + val sourceUri = Uri.parse("content://source/document.pdf") + val sink = ByteArrayOutputStream() + val contentResolver = mockk() + val insertValues = slot() + every { + contentResolver.insert( + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + capture(insertValues), + ) + } returns pendingUri + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(7, 8, 9)) + every { contentResolver.openOutputStream(pendingUri) } returns sink + every { contentResolver.update(pendingUri, any(), null, null) } returns 1 + val repository = createRepository(contentResolver = contentResolver) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "application/pdf", + contentUri = sourceUri.toString(), + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 0, + videoCount = 0, + otherCount = 1, + failCount = 0, + ), + awaitItem(), + ) + awaitComplete() + } + + assertEquals( + Environment.DIRECTORY_DOWNLOADS, + insertValues.captured.getAsString(MediaStore.MediaColumns.RELATIVE_PATH), + ) + assertEquals( + "application/pdf", + insertValues.captured.getAsString(MediaStore.MediaColumns.MIME_TYPE), + ) + assertArrayEquals(byteArrayOf(7, 8, 9), sink.toByteArray()) + } + } + + private fun createRepository( + contentResolver: ContentResolver, + ): ConversationAttachmentsRepositoryImpl { + return ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun createContentResolverForVideoPicker( + sourceUri: Uri, + contentType: String?, + scratchUri: Uri, + scratchSink: ByteArrayOutputStream = ByteArrayOutputStream(), + ): ContentResolver { + val contentResolver = mockk() + every { contentResolver.getType(sourceUri) } returns contentType + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1, 2, 3)) + every { contentResolver.openOutputStream(scratchUri) } returns scratchSink + every { contentResolver.delete(scratchUri, null, null) } returns 1 + return contentResolver + } + + @Suppress("SameParameterValue") + private fun mockScratchUri(scratchUri: Uri, extension: String?) { + mockkStatic(MediaScratchFileProvider::class) + every { + MediaScratchFileProvider.buildMediaScratchSpaceUri(extension) + } returns scratchUri + } + + private fun mockVideoMetadata( + contentResolver: ContentResolver, + scratchUri: Uri, + width: String?, + height: String?, + durationMillis: String?, + ) { + val assetFileDescriptor = mockk() + mockkConstructor(MediaMetadataRetriever::class) + every { assetFileDescriptor.fileDescriptor } returns FileDescriptor() + every { assetFileDescriptor.close() } just runs + every { + contentResolver.openAssetFileDescriptor(scratchUri, "r") + } returns assetFileDescriptor + every { + anyConstructed().setDataSource(any()) + } just runs + every { + anyConstructed() + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + } returns width + every { + anyConstructed() + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + } returns height + every { + anyConstructed() + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + } returns durationMillis + every { + anyConstructed().release() + } just runs + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt new file mode 100644 index 000000000..48672e3b5 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt @@ -0,0 +1,587 @@ +package com.android.messaging.data.subscription.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.database.MatrixCursor +import android.net.Uri +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.debug.DebugSimEmulationMode +import com.android.messaging.debug.DebugSimEmulationSource +import com.android.messaging.sms.MmsConfig +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.createParticipantsCursor +import com.android.messaging.testutil.participantRow +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.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SubscriptionsRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var contentResolver: ContentResolver + private lateinit var emulationModeFlow: MutableStateFlow + private lateinit var emulationSource: DebugSimEmulationSource + + @Before + fun setUp() { + contentResolver = mockk() + emulationModeFlow = MutableStateFlow(DebugSimEmulationMode.DEFAULT) + emulationSource = mockk() + every { emulationSource.mode } returns emulationModeFlow + } + + @After + fun tearDown() { + unmockkStatic(MmsConfig::class) + } + + @Test + fun observeActiveSubscriptions_mapsActiveSelfParticipantsAndSortsBySlot() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "self-slot-2", + subId = 2, + slotId = 1, + subscriptionName = "Carrier B", + displayDestination = "+1 555 0200", + subscriptionColor = 0x112233, + ), + participantRow( + participantId = "self-slot-1", + subId = 1, + slotId = 0, + subscriptionName = "Carrier A", + displayDestination = "+1 555 0100", + subscriptionColor = 0x445566, + ), + ), + ) + stubObserverRegistration() + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + assertEquals( + listOf( + Subscription( + selfParticipantId = "self-slot-1", + subId = 1, + label = ConversationSubscriptionLabel.Named(name = "Carrier A"), + displayDestination = "+1 555 0100", + displaySlotId = 1, + color = 0xFF445566.toInt(), + ), + Subscription( + selfParticipantId = "self-slot-2", + subId = 2, + label = ConversationSubscriptionLabel.Named(name = "Carrier B"), + displayDestination = "+1 555 0200", + displaySlotId = 2, + color = 0xFF112233.toInt(), + ), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_skipsDefaultSelfAndInactiveParticipants() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "default-self", + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + slotId = 0, + subscriptionName = "Default", + displayDestination = "+1 555 0000", + ), + participantRow( + participantId = "inactive", + subId = 5, + slotId = ParticipantData.INVALID_SLOT_ID, + subscriptionName = "Inactive", + displayDestination = "+1 555 0001", + ), + participantRow( + participantId = "active", + subId = 1, + slotId = 0, + subscriptionName = "Active", + displayDestination = "+1 555 0002", + subscriptionColor = 0xAABBCC, + ), + ), + ) + stubObserverRegistration() + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + assertEquals( + listOf( + Subscription( + selfParticipantId = "active", + subId = 1, + label = ConversationSubscriptionLabel.Named(name = "Active"), + displayDestination = "+1 555 0002", + displaySlotId = 1, + color = 0xFFAABBCC.toInt(), + ), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_emitsSlotLabelWhenSubscriptionNameIsBlank() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "self", + subId = 1, + slotId = 2, + subscriptionName = " ", + displayDestination = " ", + subscriptionColor = 0xAABBCC, + ), + ), + ) + stubObserverRegistration() + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(1, items.size) + assertEquals( + ConversationSubscriptionLabel.Slot(slotId = 3), + items.single().label, + ) + assertEquals(null, items.single().displayDestination) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_defaultModeReturnsRealSubscriptionsUnchanged() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "real", + subId = 1, + slotId = 0, + subscriptionName = "Real", + displayDestination = "+1 555 0100", + subscriptionColor = 0x112233, + ), + ), + ) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.DEFAULT + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(1, items.size) + assertEquals("real", items.single().selfParticipantId) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_singleModeKeepsRealSubscriptionWhenPresent() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "real", + subId = 1, + slotId = 0, + subscriptionName = "Real", + displayDestination = "+1 555 0100", + subscriptionColor = 0x112233, + ), + ), + ) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.SINGLE + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(1, items.size) + assertEquals("real", items.single().selfParticipantId) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_singleModeInjectsFakeSimWhenNoRealSubscriptionsExist() { + runTest(context = mainDispatcherRule.testDispatcher) { + stubQuery(cursor = createParticipantsCursor()) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.SINGLE + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(1, items.size) + val fake = items.single() + assertEquals(1, fake.displaySlotId) + assertTrue(fake.selfParticipantId.startsWith("debug_sim_emulated_")) + assertEquals( + ConversationSubscriptionLabel.DebugFake(slotId = 1), + fake.label, + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_dualModeInjectsTwoFakeSimsWhenNoRealSubscriptionsExist() { + runTest(context = mainDispatcherRule.testDispatcher) { + stubQuery(cursor = createParticipantsCursor()) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.DUAL + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(2, items.size) + assertEquals(1, items[0].displaySlotId) + assertEquals(2, items[1].displaySlotId) + assertTrue( + items.all { it.selfParticipantId.startsWith("debug_sim_emulated_") }, + ) + assertTrue( + items.all { it.label is ConversationSubscriptionLabel.DebugFake }, + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_dualModePairsSingleRealSimWithFakeInOtherSlot() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "real", + subId = 1, + slotId = 1, + subscriptionName = "Real", + displayDestination = "+1 555 0100", + subscriptionColor = 0x112233, + ), + ), + ) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.DUAL + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(2, items.size) + val bySlot = items.associateBy { it.displaySlotId } + assertEquals("real", bySlot[2]?.selfParticipantId) + assertTrue( + bySlot[1]?.selfParticipantId.orEmpty().startsWith("debug_sim_emulated_"), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_dualModePairsRealSimInSlot1WithFakeInSlot2() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "real", + subId = 1, + slotId = 0, + subscriptionName = "Real", + displayDestination = "+1 555 0100", + subscriptionColor = 0x112233, + ), + ), + ) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.DUAL + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals(2, items.size) + val bySlot = items.associateBy { it.displaySlotId } + assertEquals("real", bySlot[1]?.selfParticipantId) + assertTrue( + bySlot[2]?.selfParticipantId.orEmpty().startsWith("debug_sim_emulated_"), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_dualModePassesThroughWhenTwoRealSubscriptionsExist() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery( + cursor = createParticipantsCursor( + participantRow( + participantId = "real-a", + subId = 1, + slotId = 0, + subscriptionName = "A", + displayDestination = "+1 555 0100", + subscriptionColor = 0x112233, + ), + participantRow( + participantId = "real-b", + subId = 2, + slotId = 1, + subscriptionName = "B", + displayDestination = "+1 555 0200", + subscriptionColor = 0x445566, + ), + ), + ) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.DUAL + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + val items = awaitItem() + assertEquals( + listOf("real-a", "real-b"), + items.map { it.selfParticipantId }, + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_reemitsWhenEmulationModeChanges() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + stubQuery(cursor = createParticipantsCursor()) + stubObserverRegistration() + emulationModeFlow.value = DebugSimEmulationMode.DEFAULT + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + assertEquals(0, awaitItem().size) + + emulationModeFlow.value = DebugSimEmulationMode.SINGLE + assertEquals(1, awaitItem().size) + + emulationModeFlow.value = DebugSimEmulationMode.DUAL + assertEquals(2, awaitItem().size) + + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun observeActiveSubscriptions_registersAndUnregistersObserver() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val registeredObserver = slot() + val expectedUri = MessagingContentProvider.PARTICIPANTS_URI + + every { + contentResolver.registerContentObserver( + expectedUri, + true, + capture(registeredObserver), + ) + } just runs + every { contentResolver.unregisterContentObserver(any()) } just runs + stubQuery(cursor = createParticipantsCursor()) + + val repository = createRepository() + + repository.observeActiveSubscriptions().test { + awaitItem() + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + contentResolver.registerContentObserver( + expectedUri, + true, + registeredObserver.captured, + ) + } + verify(exactly = 1) { + contentResolver.unregisterContentObserver(registeredObserver.captured) + } + } + } + + @Test + fun resolveMaxMessageSize_returnsGlobalFallbackWhenSelfParticipantIdIsBlank() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + mockkStatic(MmsConfig::class) + every { MmsConfig.getMaxMaxMessageSize() } returns 456_000 + + val repository = createRepository() + + repository.resolveMaxMessageSize(selfParticipantId = "").test { + assertEquals(456_000, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 0) { + contentResolver.query( + any(), + any(), + any(), + any(), + any(), + ) + } + } + } + + @Test + fun resolveMaxMessageSize_usesParticipantSubscriptionMmsConfig() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + mockkStatic(MmsConfig::class) + val mmsConfig = mockk() + every { MmsConfig.get(7) } returns mmsConfig + every { mmsConfig.maxMessageSize } returns 987_000 + every { MmsConfig.getMaxMaxMessageSize() } returns 123_000 + every { + contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + arrayOf("self-7"), + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "self-7", + subId = 7, + slotId = 0, + subscriptionName = "Carrier", + displayDestination = "+1 555 7000", + ), + ) + + val repository = createRepository() + + repository.resolveMaxMessageSize(selfParticipantId = "self-7").test { + assertEquals(987_000, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + private fun createRepository(): SubscriptionsRepositoryImpl { + return SubscriptionsRepositoryImpl( + contentResolver = contentResolver, + debugSimEmulationSource = emulationSource, + defaultDispatcher = mainDispatcherRule.testDispatcher, + messagingDbDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun stubQuery(cursor: MatrixCursor) { + every { + contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns.SUB_ID} <> ?", + arrayOf(ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), + null, + ) + } returns cursor + } + + private fun stubObserverRegistration() { + every { + contentResolver.registerContentObserver( + any(), + any(), + any(), + ) + } just runs + every { contentResolver.unregisterContentObserver(any()) } just runs + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/subscriptionsettings/repository/subscriptionsettings/SubscriptionSettingsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/subscriptionsettings/repository/subscriptionsettings/SubscriptionSettingsRepositoryImplTest.kt new file mode 100644 index 000000000..8a1f93520 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/subscriptionsettings/repository/subscriptionsettings/SubscriptionSettingsRepositoryImplTest.kt @@ -0,0 +1,393 @@ +package com.android.messaging.data.subscriptionsettings.repository.subscriptionsettings + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Resources +import android.telephony.SubscriptionManager +import app.cash.turbine.test +import com.android.messaging.Factory +import com.android.messaging.R +import com.android.messaging.data.subscriptionsettings.model.SubscriptionBooleanPref +import com.android.messaging.data.subscriptionsettings.repository.SubscriptionSettingsRepositoryImpl +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.sms.MmsConfig +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.createParticipantsCursor +import com.android.messaging.testutil.participantRow +import com.android.messaging.ui.UIIntents +import com.android.messaging.util.BuglePrefs +import com.android.messaging.util.PhoneUtils +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 java.util.concurrent.Executor +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class SubscriptionSettingsRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var context: Context + private lateinit var resources: Resources + private lateinit var contentResolver: ContentResolver + private lateinit var subscriptionManager: SubscriptionManager + private lateinit var packageManager: PackageManager + private lateinit var factory: Factory + private lateinit var defaultPhoneUtils: PhoneUtils + + @Before + fun setUp() { + context = mockk(relaxed = true) + resources = mockk() + contentResolver = mockk(relaxed = true) + subscriptionManager = mockk() + packageManager = mockk() + factory = mockk(relaxed = true) + defaultPhoneUtils = mockk() + + mockkStatic(Factory::class) + mockkStatic(PhoneUtils::class) + mockkStatic(MmsConfig::class) + mockkStatic(BuglePrefs::class) + + every { Factory.get() } returns factory + every { PhoneUtils.getDefault() } returns defaultPhoneUtils + every { context.resources } returns resources + every { context.getString(R.string.mms_phone_number_pref_key) } returns + MMS_PHONE_NUMBER_PREF_KEY + every { context.getString(R.string.group_mms_pref_key) } returns GROUP_MMS_PREF_KEY + every { context.getString(R.string.auto_retrieve_mms_pref_key) } returns + AUTO_RETRIEVE_MMS_PREF_KEY + every { context.getString(R.string.auto_retrieve_mms_when_roaming_pref_key) } returns + AUTO_RETRIEVE_MMS_WHEN_ROAMING_PREF_KEY + every { context.getString(R.string.delivery_reports_pref_key) } returns + DELIVERY_REPORTS_PREF_KEY + every { resources.getBoolean(R.bool.group_mms_pref_default) } returns true + every { resources.getBoolean(R.bool.auto_retrieve_mms_pref_default) } returns true + every { resources.getBoolean(R.bool.auto_retrieve_mms_when_roaming_pref_default) } returns + false + every { resources.getBoolean(R.bool.delivery_reports_pref_default) } returns false + every { context.packageManager } returns packageManager + every { context.mainExecutor } returns Executor { runnable -> runnable.run() } + every { + packageManager.getApplicationEnabledSetting(UIIntents.CMAS_COMPONENT) + } returns PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun isMultiSim_reflectsActiveSubscriptionCount() { + every { defaultPhoneUtils.activeSubscriptionCount } returnsMany listOf(1, 2) + val repository = createRepository() + + assertFalse(repository.isMultiSim()) + assertTrue(repository.isMultiSim()) + } + + @Test + fun observeSubscriptionsChanged_registersListenerAndRemovesItOnCancel() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val listenerSlot = slot() + every { + subscriptionManager.addOnSubscriptionsChangedListener( + any(), + capture(listenerSlot), + ) + } just runs + every { + subscriptionManager.removeOnSubscriptionsChangedListener(any()) + } just runs + val repository = createRepository() + + repository.observeSubscriptionsChanged().test { + listenerSlot.captured.onSubscriptionsChanged() + assertEquals(Unit, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + subscriptionManager.addOnSubscriptionsChangedListener( + any(), + listenerSlot.captured, + ) + } + verify(exactly = 1) { + subscriptionManager.removeOnSubscriptionsChangedListener(listenerSlot.captured) + } + } + } + + @Test + fun getSubscriptionSettings_readsDefaultSubscriptionForSingleSim() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + every { defaultPhoneUtils.activeSubscriptionCount } returns 1 + every { defaultPhoneUtils.isDefaultSmsApp } returns true + stubPerSubscriptionData( + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + savedPhoneNumber = "+15550100", + defaultPhoneNumber = "+15550200", + formattedSavedPhoneNumber = "(555) 0100", + formattedDefaultPhoneNumber = "(555) 0200", + isGroupMmsSupported = true, + isGroupMmsEnabled = false, + autoRetrieveMms = true, + autoRetrieveMmsWhenRoaming = false, + isDeliveryReportsSupported = true, + deliveryReportsEnabled = true, + showCellBroadcast = true, + ) + + val result = createRepository().getSubscriptionSettings() + + assertTrue(result.isDefaultSmsApp) + assertEquals(1, result.activeSubscriptionCount) + assertTrue(result.isCellBroadcastAppEnabled) + assertEquals(ParticipantData.DEFAULT_SELF_SUB_ID, result.defaultSelfSubscription.subId) + assertEquals("+15550100", result.defaultSelfSubscription.savedPhoneNumber) + assertEquals("+15550200", result.defaultSelfSubscription.defaultPhoneNumber) + assertEquals("(555) 0100", result.defaultSelfSubscription.formattedSavedPhoneNumber) + assertEquals("(555) 0200", result.defaultSelfSubscription.formattedDefaultPhoneNumber) + assertTrue(result.defaultSelfSubscription.isGroupMmsSupported) + assertFalse(result.defaultSelfSubscription.isGroupMmsEnabled) + assertTrue(result.defaultSelfSubscription.autoRetrieveMms) + assertFalse(result.defaultSelfSubscription.autoRetrieveMmsWhenRoaming) + assertTrue(result.defaultSelfSubscription.isDeliveryReportsSupported) + assertTrue(result.defaultSelfSubscription.deliveryReportsEnabled) + assertTrue(result.defaultSelfSubscription.showCellBroadcast) + assertTrue(result.nonDefaultActiveSelfSubscriptions.isEmpty()) + verify(exactly = 0) { + contentResolver.query(any(), any(), any(), any(), any()) + } + } + } + + @Test + fun getSubscriptionSettings_readsOnlyActiveNonDefaultSubscriptionsForMultiSim() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val projectionSlot = slot>() + val selectionSlot = slot() + val selectionArgsSlot = slot>() + every { defaultPhoneUtils.activeSubscriptionCount } returns 3 + every { defaultPhoneUtils.isDefaultSmsApp } returns false + every { + contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + capture(projectionSlot), + capture(selectionSlot), + capture(selectionArgsSlot), + null, + ) + } returns createParticipantsCursor( + participantRow( + participantId = "default-self", + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + slotId = 0, + subscriptionName = "Default", + ), + participantRow( + participantId = "inactive-self", + subId = 8, + slotId = ParticipantData.INVALID_SLOT_ID, + subscriptionName = "Inactive", + ), + participantRow( + participantId = "active-self", + subId = 7, + slotId = 1, + subscriptionName = "Carrier B", + ), + ) + stubPerSubscriptionData( + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + stubPerSubscriptionData( + subId = 7, + savedPhoneNumber = "", + defaultPhoneNumber = "", + ) + + val result = createRepository().getSubscriptionSettings() + + assertFalse(result.isDefaultSmsApp) + assertEquals(3, result.activeSubscriptionCount) + assertEquals( + ParticipantData.ParticipantsQuery.PROJECTION.toList(), + projectionSlot.captured.toList(), + ) + assertEquals( + "${ParticipantColumns.SUB_ID} <> ?", + selectionSlot.captured, + ) + assertEquals( + listOf(ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), + selectionArgsSlot.captured.toList(), + ) + assertEquals(1, result.nonDefaultActiveSelfSubscriptions.size) + assertEquals(7, result.nonDefaultActiveSelfSubscriptions.single().subId) + assertEquals( + "Carrier B", + result.nonDefaultActiveSelfSubscriptions.single().subscriptionName, + ) + } + } + + @Test + fun getSubscriptionSettings_marksCellBroadcastDisabledWhenPackageLookupFails() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + every { defaultPhoneUtils.activeSubscriptionCount } returns 1 + every { defaultPhoneUtils.isDefaultSmsApp } returns true + every { + packageManager.getApplicationEnabledSetting(UIIntents.CMAS_COMPONENT) + } throws IllegalArgumentException("missing") + stubPerSubscriptionData( + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + val result = createRepository().getSubscriptionSettings() + + assertFalse(result.isCellBroadcastAppEnabled) + } + } + + @Test + fun setSubscriptionBooleanPref_writesRequestedSubscriptionPref() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val prefs = mockk() + every { BuglePrefs.getSubscriptionPrefs(7) } returns prefs + every { + prefs.putBoolean( + DELIVERY_REPORTS_PREF_KEY, + true, + ) + } just runs + + createRepository().setSubscriptionBooleanPref( + subId = 7, + pref = SubscriptionBooleanPref.DELIVERY_REPORTS, + enabled = true, + ) + + verify(exactly = 1) { + prefs.putBoolean( + DELIVERY_REPORTS_PREF_KEY, + true, + ) + } + } + } + + private fun createRepository(): SubscriptionSettingsRepositoryImpl { + return SubscriptionSettingsRepositoryImpl( + context = context, + contentResolver = contentResolver, + subscriptionManager = subscriptionManager, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun stubPerSubscriptionData( + subId: Int, + savedPhoneNumber: String = "", + defaultPhoneNumber: String = "", + formattedSavedPhoneNumber: String? = null, + formattedDefaultPhoneNumber: String? = null, + isGroupMmsSupported: Boolean = false, + isGroupMmsEnabled: Boolean = false, + autoRetrieveMms: Boolean = false, + autoRetrieveMmsWhenRoaming: Boolean = false, + isDeliveryReportsSupported: Boolean = false, + deliveryReportsEnabled: Boolean = false, + showCellBroadcast: Boolean = false, + ) { + val prefs = mockk() + val phoneUtils = mockk() + val mmsConfig = mockk() + every { factory.getSubscriptionPrefs(subId) } returns prefs + every { PhoneUtils.get(subId) } returns phoneUtils + every { MmsConfig.get(subId) } returns mmsConfig + every { + prefs.getString(MMS_PHONE_NUMBER_PREF_KEY, "") + } returns savedPhoneNumber + every { phoneUtils.getCanonicalForSelf(false) } returns defaultPhoneNumber + if (savedPhoneNumber.isNotEmpty()) { + every { phoneUtils.formatForDisplay(savedPhoneNumber) } returns + formattedSavedPhoneNumber + } + if (defaultPhoneNumber.isNotEmpty()) { + every { + phoneUtils.formatForDisplay(defaultPhoneNumber) + } returns formattedDefaultPhoneNumber + } + every { mmsConfig.groupMmsEnabled } returns isGroupMmsSupported + every { + prefs.getBoolean( + GROUP_MMS_PREF_KEY, + true, + ) + } returns isGroupMmsEnabled + every { + prefs.getBoolean( + AUTO_RETRIEVE_MMS_PREF_KEY, + true, + ) + } returns autoRetrieveMms + every { + prefs.getBoolean( + AUTO_RETRIEVE_MMS_WHEN_ROAMING_PREF_KEY, + false, + ) + } returns autoRetrieveMmsWhenRoaming + every { mmsConfig.smsDeliveryReportsEnabled } returns isDeliveryReportsSupported + every { + prefs.getBoolean( + DELIVERY_REPORTS_PREF_KEY, + false, + ) + } returns deliveryReportsEnabled + every { mmsConfig.showCellBroadcast } returns showCellBroadcast + } + + private companion object { + private const val MMS_PHONE_NUMBER_PREF_KEY = "mms_phone_number" + private const val GROUP_MMS_PREF_KEY = "group_mms" + private const val AUTO_RETRIEVE_MMS_PREF_KEY = "auto_retrieve_mms" + private const val AUTO_RETRIEVE_MMS_WHEN_ROAMING_PREF_KEY = + "auto_retrieve_mms_when_roaming" + private const val DELIVERY_REPORTS_PREF_KEY = "delivery_reports" + } +} From e5ea3b935376d35787cdfbf961d187cfa737e853 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:35:19 +0300 Subject: [PATCH 05/38] Add settings unit tests --- .../AppSettingsDelegateBindingTest.kt | 89 +++++++++++ .../AppSettingsDelegatePreferenceTest.kt | 115 +++++++++++++ .../BaseAppSettingsDelegateTest.kt | 82 ++++++++++ .../screen/SettingsNavRouteSavedStateTest.kt | 51 ++++++ .../screen/SettingsViewModelTest.kt | 0 .../SettingsEffectHandlerImplTest.kt | 148 +++++++++++++++++ .../BaseSubscriptionSettingsDelegateTest.kt | 85 ++++++++++ ...SubscriptionSettingsDelegateBindingTest.kt | 107 +++++++++++++ ...scriptionSettingsDelegatePreferenceTest.kt | 151 ++++++++++++++++++ 9 files changed, 828 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegateBindingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/SettingsNavRouteSavedStateTest.kt rename app/src/test/{java => kotlin}/com/android/messaging/ui/appsettings/screen/SettingsViewModelTest.kt (100%) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/effecthandler/SettingsEffectHandlerImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/BaseSubscriptionSettingsDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegateBindingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegatePreferenceTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegateBindingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegateBindingTest.kt new file mode 100644 index 000000000..8a5185b4d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegateBindingTest.kt @@ -0,0 +1,89 @@ +package com.android.messaging.ui.appsettings.general.delegate.appsettingsdelegate + +import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState +import io.mockk.coVerify +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class AppSettingsDelegateBindingTest : BaseAppSettingsDelegateTest() { + + @Test + fun initialState_isDefaultAndDoesNotLoadSettings() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createDelegate() + runCurrent() + + assertEquals(AppSettingsUiState(), delegate.state.value) + coVerify(exactly = 0) { repository.getAppSettings() } + verify(exactly = 0) { mapper.map(appSettings = any()) } + } + } + + @Test + fun bind_loadsSettingsAndMapsRepositoryDataIntoState() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + assertEquals(loadedState, delegate.state.value) + coVerify(exactly = 1) { repository.getAppSettings() } + verify(exactly = 1) { mapper.map(appSettings = settingsData) } + } + } + + @Test + fun bind_calledTwice_ignoresSecondBinding() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.bind(scope = backgroundScope) + runCurrent() + + coVerify(exactly = 1) { repository.getAppSettings() } + verify(exactly = 1) { mapper.map(appSettings = settingsData) } + } + } + + @Test + fun refresh_afterBind_reloadsAndRemapsState() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val delegate = createBoundDelegate() + + delegate.refresh() + runCurrent() + + assertEquals(reloadedState, delegate.state.value) + coVerify(exactly = 2) { repository.getAppSettings() } + verify(exactly = 1) { mapper.map(appSettings = reloadedSettingsData) } + } + } + + @Test + fun refresh_beforeBind_doesNotLoadSettings() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createDelegate() + + delegate.refresh() + runCurrent() + + assertEquals(AppSettingsUiState(), delegate.state.value) + coVerify(exactly = 0) { repository.getAppSettings() } + verify(exactly = 0) { mapper.map(appSettings = any()) } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt new file mode 100644 index 000000000..6fe321cab --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt @@ -0,0 +1,115 @@ +package com.android.messaging.ui.appsettings.general.delegate.appsettingsdelegate + +import com.android.messaging.data.appsettings.model.AppBooleanPref +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class AppSettingsDelegatePreferenceTest : BaseAppSettingsDelegateTest() { + + @Test + fun onSendSoundChanged_writesSendSoundPref() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onSendSoundChanged(enabled = false) + runCurrent() + + coVerify(exactly = 1) { + repository.setBooleanPref( + pref = AppBooleanPref.SEND_SOUND, + enabled = false, + ) + } + } + } + + @Test + fun onDumpSmsChanged_writesDumpSmsPref() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onDumpSmsChanged(enabled = true) + runCurrent() + + coVerify(exactly = 1) { + repository.setBooleanPref( + pref = AppBooleanPref.DUMP_SMS, + enabled = true, + ) + } + } + } + + @Test + fun onDumpMmsChanged_writesDumpMmsPref() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onDumpMmsChanged(enabled = false) + runCurrent() + + coVerify(exactly = 1) { + repository.setBooleanPref( + pref = AppBooleanPref.DUMP_MMS, + enabled = false, + ) + } + } + } + + @Test + fun booleanPrefChange_writesPrefThenRefreshesState() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val delegate = createBoundDelegate() + + delegate.onDumpSmsChanged(enabled = true) + runCurrent() + + coVerifyOrder { + repository.setBooleanPref( + pref = AppBooleanPref.DUMP_SMS, + enabled = true, + ) + repository.getAppSettings() + } + assertEquals(reloadedState, delegate.state.value) + } + } + + @Test + fun booleanPrefChange_beforeBind_doesNotWriteOrLoad() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createDelegate() + + delegate.onSendSoundChanged(enabled = false) + delegate.onDumpSmsChanged(enabled = true) + delegate.onDumpMmsChanged(enabled = true) + runCurrent() + + coVerify(exactly = 0) { + repository.setBooleanPref( + pref = any(), + enabled = any(), + ) + } + coVerify(exactly = 0) { repository.getAppSettings() } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt new file mode 100644 index 000000000..4c4949643 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt @@ -0,0 +1,82 @@ +package com.android.messaging.ui.appsettings.general.delegate.appsettingsdelegate + +import com.android.messaging.data.appsettings.model.AppSettings +import com.android.messaging.data.appsettings.repository.AppSettingsRepository +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.appsettings.general.delegate.AppSettingsDelegateImpl +import com.android.messaging.ui.appsettings.general.mapper.AppSettingsUiStateMapper +import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseAppSettingsDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected val repository = mockk() + protected val mapper = mockk() + + protected val settingsData = mockk() + protected val reloadedSettingsData = mockk() + + protected val loadedState = AppSettingsUiState( + isDefaultSmsApp = true, + defaultSmsAppLabel = "Messaging", + sendSoundEnabled = false, + isDebugEnabled = true, + dumpSmsEnabled = false, + dumpMmsEnabled = true, + ) + protected val reloadedState = AppSettingsUiState( + isDefaultSmsApp = false, + defaultSmsAppLabel = "Other SMS", + sendSoundEnabled = true, + isDebugEnabled = false, + dumpSmsEnabled = true, + dumpMmsEnabled = false, + ) + + @Before + fun setUpDefaultStubs() { + coEvery { repository.getAppSettings() } returns settingsData + every { mapper.map(appSettings = settingsData) } returns loadedState + every { mapper.map(appSettings = reloadedSettingsData) } returns reloadedState + coEvery { + repository.setBooleanPref( + pref = any(), + enabled = any(), + ) + } just Runs + } + + protected fun createDelegate(): AppSettingsDelegateImpl { + return AppSettingsDelegateImpl( + repository = repository, + mapper = mapper, + ) + } + + protected fun TestScope.createBoundDelegate(): AppSettingsDelegateImpl { + return createDelegate().also { delegate -> + delegate.bind(scope = backgroundScope) + runCurrent() + } + } + + protected fun givenSecondLoadProducesReloadedState() { + coEvery { repository.getAppSettings() } returnsMany listOf( + settingsData, + reloadedSettingsData, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/SettingsNavRouteSavedStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/SettingsNavRouteSavedStateTest.kt new file mode 100644 index 000000000..448fccdca --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/SettingsNavRouteSavedStateTest.kt @@ -0,0 +1,51 @@ +package com.android.messaging.ui.appsettings.screen + +import androidx.compose.runtime.saveable.SaverScope +import com.android.messaging.ui.appsettings.screen.model.SettingsNavRoute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +internal class SettingsNavRouteSavedStateTest { + + @Test + fun saver_roundTripsMainRoute() { + assertRoundTrip( + route = SettingsNavRoute.Main, + ) + } + + @Test + fun saver_roundTripsAppSettingsRoute() { + assertRoundTrip( + route = SettingsNavRoute.AppSettings, + ) + } + + @Test + fun saver_roundTripsSubscriptionSettingsRoute() { + assertRoundTrip( + route = SettingsNavRoute.SubscriptionSettings( + subId = 5, + title = "SIM settings", + ), + ) + } + + private fun assertRoundTrip(route: SettingsNavRoute) { + val saverScope = SaverScope { true } + val savedState = with(SettingsNavRouteSavedState.Saver) { + with(saverScope) { + save(route) + } + } + + assertNotNull(savedState) + + val restoredRoute = with(SettingsNavRouteSavedState.Saver) { + restore(savedState!!) + } + + assertEquals(route, restoredRoute) + } +} diff --git a/app/src/test/java/com/android/messaging/ui/appsettings/screen/SettingsViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/SettingsViewModelTest.kt similarity index 100% rename from app/src/test/java/com/android/messaging/ui/appsettings/screen/SettingsViewModelTest.kt rename to app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/SettingsViewModelTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/effecthandler/SettingsEffectHandlerImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/effecthandler/SettingsEffectHandlerImplTest.kt new file mode 100644 index 000000000..550303c07 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/screen/effecthandler/SettingsEffectHandlerImplTest.kt @@ -0,0 +1,148 @@ +package com.android.messaging.ui.appsettings.screen.effecthandler + +import android.app.Activity +import android.app.role.RoleManager +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.Settings +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.appsettings.screen.SettingsEffectHandlerImpl +import com.android.messaging.ui.appsettings.screen.model.SettingsScreenEffect +import com.android.messaging.ui.license.LicenseActivity +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +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) +class SettingsEffectHandlerImplTest { + + private val activity = mockk(relaxed = true) + private val roleManager = mockk() + private val uiIntents = mockk() + + @Before + fun setUp() { + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + every { activity.packageName } returns APP_PACKAGE_NAME + } + + @After + fun tearDown() { + unmockkStatic(UIIntents::class) + } + + @Test + fun handle_openWirelessAlerts_startsWirelessAlertsIntent() { + val wirelessAlertsIntent = Intent(WIRELESS_ALERTS_ACTION) + every { uiIntents.getWirelessAlertsIntent() } returns wirelessAlertsIntent + val handler = createHandler() + + handler.handle(SettingsScreenEffect.OpenWirelessAlerts(subId = 1)) + + verify(exactly = 1) { + activity.startActivity(wirelessAlertsIntent) + } + } + + @Test + fun handle_openWirelessAlerts_whenActivityIsMissing_doesNotThrow() { + val wirelessAlertsIntent = Intent(WIRELESS_ALERTS_ACTION) + every { uiIntents.getWirelessAlertsIntent() } returns wirelessAlertsIntent + every { + activity.startActivity(wirelessAlertsIntent) + } throws ActivityNotFoundException() + val handler = createHandler() + + handler.handle(SettingsScreenEffect.OpenWirelessAlerts(subId = 1)) + + verify(exactly = 1) { + activity.startActivity(wirelessAlertsIntent) + } + } + + @Test + fun handle_openManageDefaultApps_startsManageDefaultAppsSettings() { + val startedIntent = slot() + val handler = createHandler() + + handler.handle(SettingsScreenEffect.OpenManageDefaultApps) + + verify(exactly = 1) { + activity.startActivity(capture(startedIntent)) + } + assertEquals(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS, startedIntent.captured.action) + } + + @Test + fun handle_openNotificationSettings_startsPackageNotificationSettings() { + val startedIntent = slot() + val handler = createHandler() + + handler.handle(SettingsScreenEffect.OpenNotificationSettings) + + verify(exactly = 1) { + activity.startActivity(capture(startedIntent)) + } + assertEquals(Settings.ACTION_APP_NOTIFICATION_SETTINGS, startedIntent.captured.action) + assertEquals( + APP_PACKAGE_NAME, + startedIntent.captured.getStringExtra(Settings.EXTRA_APP_PACKAGE), + ) + } + + @Test + fun handle_requestDefaultSmsApp_startsRoleRequestForResult() { + val requestIntent = Intent(DEFAULT_SMS_ROLE_REQUEST_ACTION) + every { + roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) + } returns requestIntent + val handler = createHandler() + + handler.handle(SettingsScreenEffect.RequestDefaultSmsApp) + + verify(exactly = 1) { + roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) + } + verify(exactly = 1) { + activity.startActivityForResult(requestIntent, REQUEST_DEFAULT_SMS_APP) + } + } + + @Test + fun handle_openLicenses_startsLicenseActivity() { + val startedIntent = slot() + val handler = createHandler() + + handler.handle(SettingsScreenEffect.OpenLicenses) + + verify(exactly = 1) { + activity.startActivity(capture(startedIntent)) + } + assertEquals(LicenseActivity::class.java.name, startedIntent.captured.component?.className) + assertEquals(APP_PACKAGE_NAME, startedIntent.captured.component?.packageName) + } + + private fun createHandler(): SettingsEffectHandlerImpl { + return SettingsEffectHandlerImpl( + activity = activity, + roleManager = roleManager, + ) + } + + private companion object { + private const val APP_PACKAGE_NAME = "com.android.messaging" + private const val DEFAULT_SMS_ROLE_REQUEST_ACTION = "request-default-sms-role" + private const val REQUEST_DEFAULT_SMS_APP = 0 + private const val WIRELESS_ALERTS_ACTION = "wireless-alerts" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/BaseSubscriptionSettingsDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/BaseSubscriptionSettingsDelegateTest.kt new file mode 100644 index 000000000..0e003ebff --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/BaseSubscriptionSettingsDelegateTest.kt @@ -0,0 +1,85 @@ +package com.android.messaging.ui.appsettings.subscription.delegate.subscriptionsettingsdelegate + +import com.android.messaging.data.subscriptionsettings.model.SubscriptionSettingsData +import com.android.messaging.data.subscriptionsettings.repository.SubscriptionSettingsRepository +import com.android.messaging.domain.subscriptionsettings.usecase.SetSubscriptionPhoneNumber +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.appsettings.subscription.delegate.SubscriptionSettingsDelegateImpl +import com.android.messaging.ui.appsettings.subscription.mapper.SubscriptionSettingsUiStateMapper +import com.android.messaging.ui.appsettings.subscription.model.SubscriptionSettingsUiState +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseSubscriptionSettingsDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected val repository = mockk() + protected val setSubscriptionPhoneNumber = mockk() + protected val mapper = mockk() + + protected val settingsData = mockk() + protected val reloadedSettingsData = mockk() + + protected val loadedState = SubscriptionSettingsUiState( + isMultiSim = false, + isLoaded = true, + subscriptions = persistentListOf(), + ) + protected val reloadedState = SubscriptionSettingsUiState( + isMultiSim = true, + isLoaded = true, + subscriptions = persistentListOf(), + ) + + @Before + fun setUpDefaultStubs() { + every { repository.isMultiSim() } returns false + every { repository.observeSubscriptionsChanged() } returns emptyFlow() + coEvery { repository.getSubscriptionSettings() } returns settingsData + every { mapper.map(settingsData) } returns loadedState + every { mapper.map(reloadedSettingsData) } returns reloadedState + coEvery { repository.setSubscriptionBooleanPref(any(), any(), any()) } just Runs + coEvery { setSubscriptionPhoneNumber(any(), any()) } just Runs + } + + protected fun createDelegate(): SubscriptionSettingsDelegateImpl { + return SubscriptionSettingsDelegateImpl( + repository = repository, + setSubscriptionPhoneNumber = setSubscriptionPhoneNumber, + mapper = mapper, + ) + } + + protected fun TestScope.createBoundDelegate(): SubscriptionSettingsDelegateImpl { + return createDelegate().also { delegate -> + delegate.bind(backgroundScope) + runCurrent() + } + } + + protected fun givenSecondLoadProducesReloadedState() { + coEvery { + repository.getSubscriptionSettings() + } returnsMany listOf(settingsData, reloadedSettingsData) + } + + protected fun givenSubscriptionsChangedSource(): MutableSharedFlow { + val source = MutableSharedFlow(extraBufferCapacity = 1) + every { repository.observeSubscriptionsChanged() } returns source + return source + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegateBindingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegateBindingTest.kt new file mode 100644 index 000000000..2c70c9321 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegateBindingTest.kt @@ -0,0 +1,107 @@ +package com.android.messaging.ui.appsettings.subscription.delegate.subscriptionsettingsdelegate + +import com.android.messaging.ui.appsettings.subscription.model.SubscriptionSettingsUiState +import io.mockk.coVerify +import io.mockk.every +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SubscriptionSettingsDelegateBindingTest : BaseSubscriptionSettingsDelegateTest() { + + @Test + fun initialState_reflectsMultiSimFromRepositoryAndIsNotLoaded() { + every { repository.isMultiSim() } returns true + + val delegate = createDelegate() + + assertEquals( + SubscriptionSettingsUiState(isMultiSim = true), + delegate.state.value, + ) + } + + @Test + fun construction_readsMultiSimOnceAndDoesNotLoadSettings() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + createDelegate() + runCurrent() + + verify(exactly = 1) { repository.isMultiSim() } + coVerify(exactly = 0) { repository.getSubscriptionSettings() } + } + + @Test + fun bind_loadsSettingsAndMapsRepositoryDataIntoState() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + assertEquals(loadedState, delegate.state.value) + coVerify(exactly = 1) { repository.getSubscriptionSettings() } + verify(exactly = 1) { mapper.map(settingsData) } + } + + @Test + fun bind_calledTwice_ignoresSecondBinding() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.bind(backgroundScope) + runCurrent() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + repository.observeSubscriptionsChanged() + } + coVerify(exactly = 1) { repository.getSubscriptionSettings() } + } + + @Test + fun bind_whenSubscriptionsChange_reloadsAndRemapsState() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val subscriptionsChanged = givenSubscriptionsChangedSource() + val delegate = createBoundDelegate() + + subscriptionsChanged.tryEmit(Unit) + runCurrent() + + assertEquals(reloadedState, delegate.state.value) + coVerify(exactly = 2) { repository.getSubscriptionSettings() } + } + + @Test + fun refresh_afterBind_reloadsAndRemapsState() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val delegate = createBoundDelegate() + + delegate.refresh() + runCurrent() + + assertEquals(reloadedState, delegate.state.value) + coVerify(exactly = 2) { repository.getSubscriptionSettings() } + } + + @Test + fun refresh_beforeBind_doesNotLoadSettings() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createDelegate() + + delegate.refresh() + runCurrent() + + coVerify(exactly = 0) { repository.getSubscriptionSettings() } + assertEquals(SubscriptionSettingsUiState(isMultiSim = false), delegate.state.value) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegatePreferenceTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegatePreferenceTest.kt new file mode 100644 index 000000000..f77a9f4d1 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/delegate/subscriptionsettingsdelegate/SubscriptionSettingsDelegatePreferenceTest.kt @@ -0,0 +1,151 @@ +package com.android.messaging.ui.appsettings.subscription.delegate.subscriptionsettingsdelegate + +import com.android.messaging.data.subscriptionsettings.model.SubscriptionBooleanPref +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SubscriptionSettingsDelegatePreferenceTest : BaseSubscriptionSettingsDelegateTest() { + + @Test + fun onAutoRetrieveMmsChanged_writesAutoRetrieveMmsPref() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onAutoRetrieveMmsChanged(subId = 5, enabled = true) + runCurrent() + + coVerify(exactly = 1) { + repository.setSubscriptionBooleanPref( + subId = 5, + pref = SubscriptionBooleanPref.AUTO_RETRIEVE_MMS, + enabled = true, + ) + } + } + + @Test + fun onAutoRetrieveMmsWhenRoamingChanged_writesRoamingPref() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onAutoRetrieveMmsWhenRoamingChanged(subId = 5, enabled = false) + runCurrent() + + coVerify(exactly = 1) { + repository.setSubscriptionBooleanPref( + subId = 5, + pref = SubscriptionBooleanPref.AUTO_RETRIEVE_MMS_WHEN_ROAMING, + enabled = false, + ) + } + } + + @Test + fun onDeliveryReportsChanged_writesDeliveryReportsPref() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onDeliveryReportsChanged(subId = 5, enabled = true) + runCurrent() + + coVerify(exactly = 1) { + repository.setSubscriptionBooleanPref( + subId = 5, + pref = SubscriptionBooleanPref.DELIVERY_REPORTS, + enabled = true, + ) + } + } + + @Test + fun onGroupMmsChanged_writesGroupMmsPref() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createBoundDelegate() + + delegate.onGroupMmsChanged(subId = 5, enabled = false) + runCurrent() + + coVerify(exactly = 1) { + repository.setSubscriptionBooleanPref( + subId = 5, + pref = SubscriptionBooleanPref.GROUP_MMS, + enabled = false, + ) + } + } + + @Test + fun booleanPrefChange_writesPrefThenRefreshesState() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val delegate = createBoundDelegate() + + delegate.onGroupMmsChanged(subId = 5, enabled = true) + runCurrent() + + coVerifyOrder { + repository.setSubscriptionBooleanPref( + subId = 5, + pref = SubscriptionBooleanPref.GROUP_MMS, + enabled = true, + ) + repository.getSubscriptionSettings() + } + assertEquals(reloadedState, delegate.state.value) + } + + @Test + fun booleanPrefChange_beforeBind_doesNotWriteOrLoad() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createDelegate() + + delegate.onGroupMmsChanged(subId = 5, enabled = true) + runCurrent() + + coVerify(exactly = 0) { + repository.setSubscriptionBooleanPref(any(), any(), any()) + } + coVerify(exactly = 0) { repository.getSubscriptionSettings() } + } + + @Test + fun onPhoneNumberChanged_afterBind_setsNumberThenRefreshesState() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val delegate = createBoundDelegate() + + delegate.onPhoneNumberChanged(subId = 3, phoneNumber = "+15550001") + runCurrent() + + coVerifyOrder { + setSubscriptionPhoneNumber(subId = 3, phoneNumber = "+15550001") + repository.getSubscriptionSettings() + } + assertEquals(reloadedState, delegate.state.value) + } + + @Test + fun onPhoneNumberChanged_beforeBind_doesNotSetNumber() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val delegate = createDelegate() + + delegate.onPhoneNumberChanged(subId = 3, phoneNumber = "+15550001") + runCurrent() + + coVerify(exactly = 0) { setSubscriptionPhoneNumber(any(), any()) } + } +} From 0c7c2a616a4ea12d4a8d15af713cbad7c11db810 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:35:46 +0300 Subject: [PATCH 06/38] Add conversation composer unit tests --- ...etConversationDraftSendProtocolImplTest.kt | 190 ++++++ .../draft/SendConversationDraftImplTest.kt | 613 ++++++++++++++++++ ...tionComposerAttachmentsDelegateImplTest.kt | 322 +++++++++ ...BaseConversationDraftEditorDelegateTest.kt | 146 +++++ ...ationDraftEditorDelegateAttachmentsTest.kt | 179 +++++ ...raftEditorDelegatePendingResolutionTest.kt | 133 ++++ ...ConversationDraftEditorDelegateSaveTest.kt | 160 +++++ ...ConversationDraftEditorDelegateSeedTest.kt | 88 +++ ...ionDraftEditorDelegateSendLifecycleTest.kt | 160 +++++ ...ftEditorDelegateSendProtocolUpdatesTest.kt | 182 ++++++ ...nDraftEditorDelegateStateProjectionTest.kt | 93 +++ .../BaseConversationDraftDelegateTest.kt | 232 +++++++ ...tionDraftDelegateActionRequirementsTest.kt | 212 ++++++ .../ConversationDraftDelegateAutosaveTest.kt | 97 +++ ...onversationDraftDelegateObservationTest.kt | 99 +++ ...nversationDraftDelegateSendProtocolTest.kt | 208 ++++++ .../ConversationDraftDelegateSendTest.kt | 174 +++++ ...ersationDraftDelegateSendValidationTest.kt | 204 ++++++ .../BaseDraftEditorStateTest.kt | 85 +++ .../ConversationDraftEditsTest.kt | 129 ++++ .../DraftEditorStateAttachmentsTest.kt | 153 +++++ .../DraftEditorStateEffectiveDraftTest.kt | 82 +++ .../DraftEditorStatePendingAttachmentsTest.kt | 56 ++ .../DraftEditorStatePersistedDraftTest.kt | 83 +++ .../DraftEditorStateSaveTest.kt | 215 ++++++ .../DraftEditorStateSeededDraftTest.kt | 73 +++ .../DraftEditorStateSendLifecycleTest.kt | 158 +++++ .../DraftEditorStateTextEditsTest.kt | 101 +++ ...ComposerAttachmentUiModelMapperImplTest.kt | 152 +++++ ...nversationComposerUiStateMapperImplTest.kt | 290 ++++++++- 30 files changed, 5061 insertions(+), 8 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocolImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/attachments/ConversationComposerAttachmentsDelegateImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/BaseConversationDraftEditorDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateAttachmentsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegatePendingResolutionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSaveTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSeedTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendLifecycleTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendProtocolUpdatesTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/BaseConversationDraftDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateActionRequirementsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateAutosaveTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateObservationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendProtocolTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/BaseDraftEditorStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/ConversationDraftEditsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateAttachmentsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateEffectiveDraftTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePendingAttachmentsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePersistedDraftTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSaveTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSeededDraftTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSendLifecycleTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateTextEditsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapperImplTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocolImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocolImplTest.kt new file mode 100644 index 000000000..f38b287e9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocolImplTest.kt @@ -0,0 +1,190 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.datamodel.MessageTextStats +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.sms.MmsUtils +import com.android.messaging.testutil.TEST_SELF_SUB_ID as SELF_SUB_ID +import com.android.messaging.testutil.createConversationMetadata +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +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) +class GetConversationDraftSendProtocolImplTest { + + @Before + fun setUp() { + unmockkAll() + mockkStatic(MmsSmsUtils::class) + mockkStatic(MmsUtils::class) + mockkConstructor(MessageTextStats::class) + + every { + MmsSmsUtils.getRequireMmsForEmailAddress(any(), any()) + } returns false + every { MmsUtils.groupMmsEnabled(any()) } returns false + every { + anyConstructed().updateMessageTextStats(any(), any()) + } just runs + every { anyConstructed().messageLengthRequiresMms } returns false + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun invoke_whenDraftHasAttachment_returnsMms() { + val result = createUseCase().invoke( + draft = ConversationDraft( + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "image/png", + contentUri = "content://image/1", + ), + ), + ), + sendData = createSendData(), + ) + + assertEquals(ConversationDraftSendProtocol.MMS, result) + } + + @Test + fun invoke_whenDraftHasSubject_returnsMms() { + val result = createUseCase().invoke( + draft = ConversationDraft( + messageText = "Hello", + subjectText = "Subject", + ), + sendData = createSendData(), + ) + + assertEquals(ConversationDraftSendProtocol.MMS, result) + } + + @Test + fun invoke_whenGroupConversationRequiresMms_returnsMms() { + every { MmsUtils.groupMmsEnabled(SELF_SUB_ID) } returns true + + val result = createUseCase().invoke( + draft = ConversationDraft( + messageText = "Hello", + ), + sendData = createSendData( + metadata = createConversationMetadata(isGroupConversation = true), + ), + ) + + assertEquals(ConversationDraftSendProtocol.MMS, result) + verify(exactly = 1) { + MmsUtils.groupMmsEnabled(SELF_SUB_ID) + } + } + + @Test + fun invoke_whenEmailAddressRequiresMms_returnsMms() { + every { + MmsSmsUtils.getRequireMmsForEmailAddress(true, SELF_SUB_ID) + } returns true + + val result = createUseCase().invoke( + draft = ConversationDraft( + messageText = "Hello", + ), + sendData = createSendData( + metadata = createConversationMetadata(includeEmailAddress = true), + ), + ) + + assertEquals(ConversationDraftSendProtocol.MMS, result) + verify(exactly = 1) { + MmsSmsUtils.getRequireMmsForEmailAddress(true, SELF_SUB_ID) + } + } + + @Test + fun invoke_whenMessageLengthRequiresMms_returnsMms() { + every { anyConstructed().messageLengthRequiresMms } returns true + + val result = createUseCase().invoke( + draft = ConversationDraft( + messageText = "A long message", + ), + sendData = createSendData(), + ) + + assertEquals(ConversationDraftSendProtocol.MMS, result) + verify(exactly = 1) { + anyConstructed().updateMessageTextStats( + SELF_SUB_ID, + "A long message", + ) + } + } + + @Test + fun invoke_whenNoMmsConditionMatches_returnsSms() { + val result = createUseCase().invoke( + draft = ConversationDraft( + messageText = "Hello", + ), + sendData = createSendData(), + ) + + assertEquals(ConversationDraftSendProtocol.SMS, result) + } + + @Test + fun invoke_whenSelfParticipantIsMissing_usesDefaultSelfSubIdForTextStats() { + val result = createUseCase().invoke( + draft = ConversationDraft( + messageText = "Hello", + ), + sendData = createSendData(selfParticipant = null), + ) + + assertEquals(ConversationDraftSendProtocol.SMS, result) + verify(exactly = 1) { + anyConstructed().updateMessageTextStats( + ParticipantData.DEFAULT_SELF_SUB_ID, + "Hello", + ) + } + } + + private fun createUseCase(): GetConversationDraftSendProtocolImpl { + return GetConversationDraftSendProtocolImpl() + } + + private fun createSendData( + metadata: ConversationMetadata = createConversationMetadata(), + selfParticipant: ParticipantData? = ParticipantData.getSelfParticipant(SELF_SUB_ID), + ): ConversationSendData { + return ConversationSendData( + metadata = metadata, + participants = mockk(), + selfParticipant = selfParticipant, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt new file mode 100644 index 000000000..4400adae0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt @@ -0,0 +1,613 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import app.cash.turbine.test +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.datamodel.action.InsertNewMessageAction +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.draft.exception.BlankConversationIdException +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationRecipientsNotLoadedException +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException +import com.android.messaging.domain.conversation.usecase.draft.exception.DraftDispatchFailedException +import com.android.messaging.domain.conversation.usecase.draft.exception.EmptyConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.MissingSelfPhoneNumberForGroupMmsException +import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException +import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsUtils +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.TEST_SELF_SUB_ID as SELF_SUB_ID +import com.android.messaging.testutil.createConversationMetadata +import com.android.messaging.util.PhoneUtils +import io.mockk.coEvery +import io.mockk.coVerify +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 kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SendConversationDraftImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val defaultPhoneUtils = mockk(relaxed = true) + + @Before + fun setUp() { + unmockkAll() + mockkStatic(InsertNewMessageAction::class) + mockkStatic(PhoneUtils::class) + every { InsertNewMessageAction.insertNewMessage(any()) } just runs + every { + InsertNewMessageAction.insertNewMessage( + any(), + any(), + ) + } just runs + every { PhoneUtils.getDefault() } returns defaultPhoneUtils + every { + defaultPhoneUtils.defaultSmsSubscriptionId + } returns ParticipantData.DEFAULT_SELF_SUB_ID + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun invoke_throwsWhenConversationIdIsBlank() { + runTest(context = mainDispatcherRule.testDispatcher) { + val exception = collectFailure( + createUseCase().invoke( + conversationId = " ", + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(BlankConversationIdException::class.java, exception.javaClass) + } + } + + @Test + fun invoke_throwsWhenDraftIsEmpty() { + runTest(context = mainDispatcherRule.testDispatcher) { + val exception = collectFailure( + createUseCase().invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft(), + ), + ) + + assertEquals(EmptyConversationDraftException::class.java, exception.javaClass) + } + } + + @Test + fun invoke_isColdUntilCollected() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = createConversationsRepositoryMock() + val mapper = createConversationDraftMessageDataMapperMock() + val getSendProtocol = createGetSendProtocolMock() + val useCase = createUseCase( + repository = repository, + mapper = mapper, + getSendProtocol = getSendProtocol, + ) + + @Suppress("UnusedFlow") + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ) + + coVerify(exactly = 0) { + repository.getConversationSendData(any(), any()) + } + verify(exactly = 0) { + getSendProtocol.invoke(any(), any()) + } + verify(exactly = 0) { + mapper.map( + conversationId = any(), + draft = any(), + forceMms = any(), + ) + } + verify(exactly = 0) { + InsertNewMessageAction.insertNewMessage(any()) + } + } + } + + @Test + fun invoke_mapsDraftAndSendsMessageOnCollection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messageData = createMessageData() + val sendData = createSendData() + val repository = createConversationsRepositoryMock(sendData = sendData) + val mapper = createConversationDraftMessageDataMapperMock( + messageToReturn = messageData, + ) + val getSendProtocol = createGetSendProtocolMock( + protocol = ConversationDraftSendProtocol.SMS, + ) + val draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "self-1", + ) + val useCase = createUseCase( + repository = repository, + mapper = mapper, + getSendProtocol = getSendProtocol, + dispatcher = mainDispatcherRule.testDispatcher, + ) + + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = draft, + ).test { + assertEquals(Unit, awaitItem()) + awaitComplete() + } + + coVerify(exactly = 1) { + repository.getConversationSendData( + conversationId = CONVERSATION_ID, + requestedSelfParticipantId = "self-1", + ) + } + verify(exactly = 1) { + getSendProtocol.invoke( + draft = draft, + sendData = sendData, + ) + } + verify(exactly = 1) { + mapper.map( + conversationId = CONVERSATION_ID, + draft = draft, + forceMms = false, + ) + } + verify(exactly = 1) { + InsertNewMessageAction.insertNewMessage(messageData) + } + } + } + + @Test + fun invoke_forcesMmsWhenProtocolUseCaseReturnsMms() { + runTest(context = mainDispatcherRule.testDispatcher) { + val mapper = createConversationDraftMessageDataMapperMock() + val draft = ConversationDraft( + messageText = "Hello", + ) + val useCase = createUseCase( + mapper = mapper, + getSendProtocol = createGetSendProtocolMock( + protocol = ConversationDraftSendProtocol.MMS, + ), + ) + + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = draft, + ).test { + assertEquals(Unit, awaitItem()) + awaitComplete() + } + + verify(exactly = 1) { + mapper.map( + conversationId = CONVERSATION_ID, + draft = draft, + forceMms = true, + ) + } + } + } + + @Test + fun invoke_throwsWhenConversationSendDataIsMissing() { + runTest(context = mainDispatcherRule.testDispatcher) { + val exception = collectFailure( + createUseCase( + repository = createConversationsRepositoryMock(sendData = null), + ).invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals( + ConversationRecipientsNotLoadedException::class.java, + exception.javaClass, + ) + } + } + + @Test + fun invoke_throwsWhenParticipantsAreNotLoaded() { + runTest(context = mainDispatcherRule.testDispatcher) { + val exception = collectFailure( + createUseCase( + repository = createConversationsRepositoryMock( + sendData = createSendData( + participants = createParticipantsData(loaded = false), + ), + ), + ).invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals( + ConversationRecipientsNotLoadedException::class.java, + exception.javaClass, + ) + } + } + + @Test + fun invoke_throwsWhenConversationContainsUnknownRecipient() { + runTest(context = mainDispatcherRule.testDispatcher) { + val unknownParticipant = mockk() + every { unknownParticipant.isUnknownSender } returns true + val exception = collectFailure( + createUseCase( + repository = createConversationsRepositoryMock( + sendData = createSendData( + participants = createParticipantsData( + participantList = listOf(unknownParticipant), + ), + ), + ), + ).invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(UnknownConversationRecipientException::class.java, exception.javaClass) + } + } + + @Test + fun invoke_throwsWhenGroupMmsSelfPhoneNumberIsMissing() { + runTest(context = mainDispatcherRule.testDispatcher) { + every { PhoneUtils.get(SELF_SUB_ID) } returns defaultPhoneUtils + every { defaultPhoneUtils.getSelfRawNumber(true) } returns null + + val exception = collectFailure( + createUseCase( + repository = createConversationsRepositoryMock( + sendData = createSendData( + metadata = createConversationMetadata(isGroupConversation = true), + ), + ), + getSendProtocol = createGetSendProtocolMock( + protocol = ConversationDraftSendProtocol.MMS, + ), + ).invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals( + MissingSelfPhoneNumberForGroupMmsException::class.java, + exception.javaClass, + ) + } + } + + @Test + fun invoke_throwsWhenGroupMmsSelfPhoneNumberCannotBeRead() { + runTest(context = mainDispatcherRule.testDispatcher) { + every { PhoneUtils.get(SELF_SUB_ID) } returns defaultPhoneUtils + every { + defaultPhoneUtils.getSelfRawNumber(true) + } throws IllegalStateException("sim not ready") + + val exception = collectFailure( + createUseCase( + repository = createConversationsRepositoryMock( + sendData = createSendData( + metadata = createConversationMetadata(isGroupConversation = true), + ), + ), + getSendProtocol = createGetSendProtocolMock( + protocol = ConversationDraftSendProtocol.MMS, + ), + ).invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(ConversationSimNotReadyException::class.java, exception.javaClass) + assertEquals("sim not ready", exception.cause?.message) + } + } + + @Test + fun invoke_throwsWhenDraftHasTooManyVideoAttachments() { + runTest(context = mainDispatcherRule.testDispatcher) { + val videoAttachments = List(MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT + 1) { index -> + ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = "content://video/$index", + ) + }.toPersistentList() + + val exception = collectFailure( + createUseCase().invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + attachments = videoAttachments, + ), + ), + ) + + assertEquals(TooManyVideoAttachmentsException::class.java, exception.javaClass) + } + } + + @Test + fun invoke_locksDefaultSelfMessageToSystemDefaultSubscription() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messageData = createMessageData() + every { defaultPhoneUtils.defaultSmsSubscriptionId } returns SELF_SUB_ID + val useCase = createUseCase( + mapper = createConversationDraftMessageDataMapperMock( + messageToReturn = messageData, + ), + repository = createConversationsRepositoryMock( + sendData = createSendData( + selfParticipant = ParticipantData.getSelfParticipant( + ParticipantData.DEFAULT_SELF_SUB_ID, + ), + ), + ), + ) + + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ).test { + assertEquals(Unit, awaitItem()) + awaitComplete() + } + + verify(exactly = 1) { + InsertNewMessageAction.insertNewMessage(messageData, SELF_SUB_ID) + } + verify(exactly = 0) { + InsertNewMessageAction.insertNewMessage(messageData) + } + } + } + + @Test + fun invoke_wrapsMapperFailures() { + runTest(context = mainDispatcherRule.testDispatcher) { + val mapper = createConversationDraftMessageDataMapperMock( + failure = IllegalStateException("mapper failure"), + ) + val useCase = createUseCase(mapper = mapper) + + val exception = collectFailure( + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(DraftDispatchFailedException::class.java, exception.javaClass) + assertEquals("mapper failure", exception.cause?.message) + } + } + + @Test + fun invoke_wrapsSenderFailures() { + runTest(context = mainDispatcherRule.testDispatcher) { + every { + InsertNewMessageAction.insertNewMessage(any()) + } throws IllegalStateException("sender failure") + val useCase = createUseCase() + + val exception = collectFailure( + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(DraftDispatchFailedException::class.java, exception.javaClass) + assertEquals("sender failure", exception.cause?.message) + } + } + + @Test + fun invoke_rethrowsCancellation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val cancellationException = CancellationException("cancelled") + every { + InsertNewMessageAction.insertNewMessage(any()) + } throws cancellationException + val useCase = createUseCase() + + val exception = collectFailure( + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(CancellationException::class.java, exception.javaClass) + assertEquals(cancellationException.message, exception.message) + } + } + + private suspend fun collectFailure(flow: Flow): Throwable { + try { + flow.collect() + } catch (exception: Throwable) { + return exception + } + + fail("Expected flow collection to fail.") + error("Unreachable") + } + + private fun createUseCase( + repository: ConversationsRepository = createConversationsRepositoryMock(), + subscriptionsRepository: SubscriptionsRepository = createSubscriptionsRepositoryMock(), + mapper: ConversationDraftMessageDataMapper = createConversationDraftMessageDataMapperMock(), + getSendProtocol: GetConversationDraftSendProtocol = createGetSendProtocolMock(), + dispatcher: TestDispatcher = mainDispatcherRule.testDispatcher, + ): SendConversationDraftImpl { + return SendConversationDraftImpl( + conversationsRepository = repository, + subscriptionsRepository = subscriptionsRepository, + getConversationDraftSendProtocol = getSendProtocol, + conversationDraftMessageDataMapper = mapper, + ioDispatcher = dispatcher, + ) + } + + private fun createConversationsRepositoryMock( + sendData: ConversationSendData? = createSendData(), + ): ConversationsRepository { + val repository = mockk(relaxed = true) + coEvery { + repository.getConversationSendData( + conversationId = any(), + requestedSelfParticipantId = any(), + ) + } returns sendData + return repository + } + + private fun createSubscriptionsRepositoryMock(): SubscriptionsRepository { + val repository = mockk(relaxed = true) + every { repository.resolveAttachmentLimit() } returns Int.MAX_VALUE + return repository + } + + private fun createGetSendProtocolMock( + protocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, + ): GetConversationDraftSendProtocol { + val getSendProtocol = mockk() + every { + getSendProtocol.invoke( + draft = any(), + sendData = any(), + ) + } returns protocol + return getSendProtocol + } + + private fun createConversationDraftMessageDataMapperMock( + messageToReturn: MessageData = createMessageData(), + failure: Exception? = null, + ): ConversationDraftMessageDataMapper { + val mapper = mockk() + every { + mapper.map( + conversationId = any(), + draft = any(), + forceMms = any(), + ) + } answers { + failure?.let { exception -> + throw exception + } + messageToReturn + } + return mapper + } + + private fun createSendData( + metadata: ConversationMetadata = createConversationMetadata(), + participants: ConversationParticipantsData = createParticipantsData(), + selfParticipant: ParticipantData? = ParticipantData.getSelfParticipant(SELF_SUB_ID), + ): ConversationSendData { + return ConversationSendData( + metadata = metadata, + participants = participants, + selfParticipant = selfParticipant, + ) + } + + private fun createParticipantsData( + loaded: Boolean = true, + participantList: List = emptyList(), + ): ConversationParticipantsData { + val participants = mockk() + every { participants.isLoaded } returns loaded + every { participants.iterator() } returns participantList.toMutableList().iterator() + return participants + } + + private fun createMessageData(): MessageData { + return MessageData.createDraftSmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/attachments/ConversationComposerAttachmentsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/attachments/ConversationComposerAttachmentsDelegateImplTest.kt new file mode 100644 index 000000000..218747db6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/attachments/ConversationComposerAttachmentsDelegateImplTest.kt @@ -0,0 +1,322 @@ +package com.android.messaging.ui.conversation.composer.delegate.attachments + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegateImpl +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationComposerAttachmentsDelegateImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun bind_seedsInitialStateFromCurrentDraftState() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentMapper = mockk() + val vCardUiModelMapper = mockk() + val metadataRepository = mockk() + val expectedState = persistentListOf( + ComposerAttachmentUiModel.Resolved.File( + key = "content://attachments/file/1", + contentType = "application/pdf", + contentUri = "content://attachments/file/1", + ), + ) + every { + attachmentMapper.map(any(), any()) + } returns expectedState + val delegate = ConversationComposerAttachmentsDelegateImpl( + conversationComposerAttachmentUiModelMapper = attachmentMapper, + conversationVCardAttachmentUiModelMapper = vCardUiModelMapper, + conversationVCardMetadataRepository = metadataRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + draftStateFlow = MutableStateFlow( + ConversationDraftState( + draft = ConversationDraft( + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "application/pdf", + contentUri = "content://attachments/file/1", + ), + ), + ), + ), + ), + ) + assertEquals(expectedState, delegate.state.value) + + advanceUntilIdle() + + assertEquals(expectedState, delegate.state.value) + } finally { + boundScope.cancel() + } + } + } + + @Test + fun bind_updatesVCardAttachmentWhenMetadataArrives() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentMapper = mockk() + val vCardUiModelMapper = mockk() + val metadataRepository = mockk() + val loadingUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleTextResId = 1, + subtitleTextResId = 2, + ) + val loadedMetadata = ConversationVCardAttachmentMetadata.Loaded( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + displayName = "Sam Rivera", + details = "555-000-8901", + locationAddress = null, + ) + val loadedUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + subtitleText = "555-000-8901", + ) + val initialAttachment = ComposerAttachmentUiModel.Resolved.VCard( + key = "content://attachments/vcard/1", + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + vCardUiModel = loadingUiModel, + ) + every { + attachmentMapper.map(any(), any()) + } returns persistentListOf(initialAttachment) + every { + metadataRepository.observeAttachmentMetadata( + contentUri = "content://attachments/vcard/1", + ) + } returns flowOf(loadedMetadata) + every { + vCardUiModelMapper.map(metadata = loadedMetadata) + } returns loadedUiModel + val delegate = ConversationComposerAttachmentsDelegateImpl( + conversationComposerAttachmentUiModelMapper = attachmentMapper, + conversationVCardAttachmentUiModelMapper = vCardUiModelMapper, + conversationVCardMetadataRepository = metadataRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + draftStateFlow = MutableStateFlow( + ConversationDraftState( + draft = ConversationDraft( + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + ), + ), + ), + ), + ), + ) + advanceUntilIdle() + + assertEquals( + persistentListOf( + initialAttachment.copy(vCardUiModel = loadedUiModel), + ), + delegate.state.value, + ) + } finally { + boundScope.cancel() + } + } + } + + @Test + fun bind_doesNotRestartObservationWhenOnlyDraftTextChanges() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentMapper = mockk() + val vCardUiModelMapper = mockk() + val metadataRepository = mockk() + val draftStateFlow = MutableStateFlow( + ConversationDraftState( + draft = ConversationDraft( + messageText = "hello", + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "application/pdf", + contentUri = "content://attachments/file/1", + ), + ), + ), + ), + ) + every { + attachmentMapper.map(any(), any()) + } returns persistentListOf( + ComposerAttachmentUiModel.Resolved.File( + key = "content://attachments/file/1", + contentType = "application/pdf", + contentUri = "content://attachments/file/1", + ), + ) + val delegate = ConversationComposerAttachmentsDelegateImpl( + conversationComposerAttachmentUiModelMapper = attachmentMapper, + conversationVCardAttachmentUiModelMapper = vCardUiModelMapper, + conversationVCardMetadataRepository = metadataRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + draftStateFlow = draftStateFlow, + ) + advanceUntilIdle() + + verify(exactly = 2) { + attachmentMapper.map(any(), any()) + } + + draftStateFlow.value = draftStateFlow.value.copy( + draft = draftStateFlow.value.draft.copy( + messageText = "updated text", + ), + ) + advanceUntilIdle() + + verify(exactly = 2) { + attachmentMapper.map(any(), any()) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun bind_observesDuplicateVCardUrisOnlyOnce() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentMapper = mockk() + val vCardUiModelMapper = mockk() + val metadataRepository = mockk() + every { + attachmentMapper.map(any(), any()) + } returns persistentListOf( + createVCardAttachment(), + createVCardAttachment(), + ) + every { + metadataRepository.observeAttachmentMetadata( + contentUri = "content://attachments/vcard/1", + ) + } returns flowOf(ConversationVCardAttachmentMetadata.Loading) + every { + vCardUiModelMapper.map(metadata = ConversationVCardAttachmentMetadata.Loading) + } returns ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleTextResId = 1, + subtitleTextResId = 2, + ) + val delegate = ConversationComposerAttachmentsDelegateImpl( + conversationComposerAttachmentUiModelMapper = attachmentMapper, + conversationVCardAttachmentUiModelMapper = vCardUiModelMapper, + conversationVCardMetadataRepository = metadataRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + draftStateFlow = MutableStateFlow( + ConversationDraftState( + draft = ConversationDraft( + attachments = persistentListOf( + ConversationDraftAttachment( + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + ), + ConversationDraftAttachment( + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + ), + ), + ), + ), + ), + ) + advanceUntilIdle() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + metadataRepository.observeAttachmentMetadata( + contentUri = "content://attachments/vcard/1", + ) + } + } finally { + boundScope.cancel() + } + } + } + + private fun createVCardAttachment(): ComposerAttachmentUiModel.Resolved.VCard { + return ComposerAttachmentUiModel.Resolved.VCard( + key = "content://attachments/vcard/1", + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleTextResId = 1, + subtitleTextResId = 2, + ), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/BaseConversationDraftEditorDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/BaseConversationDraftEditorDelegateTest.kt new file mode 100644 index 000000000..eeddbe5c2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/BaseConversationDraftEditorDelegateTest.kt @@ -0,0 +1,146 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimit +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftEditorDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.PersistedDraftUpdate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationDraftEditorDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected val subscriptionsRepository = mockk() + protected val resolveConversationDraftSendProtocol = + mockk() + protected val resolveDraftAttachmentsWithinLimit = mockk() + + @Before + fun setUpDefaultCollaboratorStubs() { + every { subscriptionsRepository.resolveAttachmentLimit() } returns DEFAULT_ATTACHMENT_LIMIT + coEvery { + resolveConversationDraftSendProtocol(conversationId = any(), draft = any()) + } returns ConversationDraftSendProtocol.SMS + } + + protected fun createDelegate(): ConversationDraftEditorDelegateImpl { + return ConversationDraftEditorDelegateImpl( + subscriptionsRepository = subscriptionsRepository, + resolveConversationDraftSendProtocol = resolveConversationDraftSendProtocol, + resolveDraftAttachmentsWithinLimit = resolveDraftAttachmentsWithinLimit, + ) + } + + protected fun loadedDelegate( + conversationId: String = CONVERSATION_ID, + persistedDraft: ConversationDraft = draft(), + ): ConversationDraftEditorDelegateImpl { + return createDelegate().also { delegate -> + delegate.reset(conversationId = conversationId) + delegate.applyPersistedDraftUpdate( + persistedDraftUpdate = PersistedDraftUpdate( + conversationId = conversationId, + persistedDraft = persistedDraft, + ), + ) + } + } + + protected fun persistedDraftUpdate( + conversationId: String = CONVERSATION_ID, + persistedDraft: ConversationDraft = draft(), + ): PersistedDraftUpdate { + return PersistedDraftUpdate( + conversationId = conversationId, + persistedDraft = persistedDraft, + ) + } + + @Suppress("SameParameterValue") + protected fun givenAttachmentLimit(limit: Int) { + every { subscriptionsRepository.resolveAttachmentLimit() } returns limit + } + + protected fun givenResolvedSendProtocol(protocol: ConversationDraftSendProtocol) { + coEvery { + resolveConversationDraftSendProtocol(conversationId = any(), draft = any()) + } returns protocol + } + + protected fun givenAttachmentLimitResult( + attachmentsToAdd: List, + didDropAttachments: Boolean, + ) { + every { + resolveDraftAttachmentsWithinLimit( + currentAttachments = any(), + attachmentsToAdd = any(), + ) + } returns DraftAttachmentLimitResult( + attachmentsToAdd = attachmentsToAdd, + didDropAttachments = didDropAttachments, + ) + } + + protected fun draft( + messageText: String = "", + subjectText: String = "", + selfParticipantId: String = "", + attachments: List = emptyList(), + ): ConversationDraft { + return ConversationDraft( + messageText = messageText, + subjectText = subjectText, + selfParticipantId = selfParticipantId, + attachments = attachments.toImmutableList(), + ) + } + + protected fun attachment( + contentUri: String, + contentType: String = "image/jpeg", + captionText: String = "", + ): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = captionText, + ) + } + + protected fun pendingAttachment( + pendingAttachmentId: String, + contentUri: String = "content://pending/$pendingAttachmentId", + contentType: String = "image/jpeg", + kind: ConversationDraftPendingAttachmentKind = + ConversationDraftPendingAttachmentKind.Generic, + ): ConversationDraftPendingAttachment { + return ConversationDraftPendingAttachment( + pendingAttachmentId = pendingAttachmentId, + contentUri = contentUri, + contentType = contentType, + kind = kind, + ) + } + + protected companion object { + const val DEFAULT_ATTACHMENT_LIMIT = 10 + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateAttachmentsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateAttachmentsTest.kt new file mode 100644 index 000000000..dd2f67fb0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateAttachmentsTest.kt @@ -0,0 +1,179 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationDraftEditorDelegateAttachmentsTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun addAttachments_withEmptyInput_returnsEmptyResultWithoutResolvingLimit() { + val delegate = loadedDelegate() + + val result = delegate.addAttachments(attachments = emptyList()) + + assertEquals( + DraftAttachmentLimitResult(attachmentsToAdd = emptyList(), didDropAttachments = false), + result, + ) + verify(exactly = 0) { + resolveDraftAttachmentsWithinLimit(currentAttachments = any(), attachmentsToAdd = any()) + } + assertTrue(delegate.state.value.draft.attachments.isEmpty()) + } + + @Test + fun addAttachments_withAcceptedAttachments_addsThemAndReturnsResult() { + val delegate = loadedDelegate() + val attachments = listOf( + attachment(contentUri = "content://a/1"), + attachment(contentUri = "content://a/2"), + ) + givenAttachmentLimitResult(attachmentsToAdd = attachments, didDropAttachments = false) + + val result = delegate.addAttachments(attachments = attachments) + + assertEquals( + DraftAttachmentLimitResult(attachmentsToAdd = attachments, didDropAttachments = false), + result, + ) + assertEquals(attachments, delegate.state.value.draft.attachments.toList()) + } + + @Test + fun addAttachments_whenEveryCandidateIsDropped_returnsResultButLeavesStateUnchanged() { + val delegate = loadedDelegate() + givenAttachmentLimitResult(attachmentsToAdd = emptyList(), didDropAttachments = true) + + val result = delegate.addAttachments( + attachments = listOf(attachment(contentUri = "content://a/1")), + ) + + assertEquals( + DraftAttachmentLimitResult(attachmentsToAdd = emptyList(), didDropAttachments = true), + result, + ) + assertTrue(delegate.state.value.draft.attachments.isEmpty()) + } + + @Test + fun addAttachments_resolvesLimitAgainstCurrentEffectiveAttachments() { + val delegate = loadedDelegate( + persistedDraft = draft(attachments = listOf(attachment(contentUri = "content://a/1"))), + ) + val candidate = attachment(contentUri = "content://a/2") + givenAttachmentLimitResult(attachmentsToAdd = listOf(candidate), didDropAttachments = false) + + delegate.addAttachments(attachments = listOf(candidate)) + + verify(exactly = 1) { + resolveDraftAttachmentsWithinLimit( + currentAttachments = match> { current -> + current.map { attachment -> attachment.contentUri } == listOf("content://a/1") + }, + attachmentsToAdd = listOf(candidate), + ) + } + } + + @Test + fun tryStartAddingAttachment_whenBelowLimit_returnsTrue() { + val delegate = loadedDelegate() + givenAttachmentLimit(limit = 2) + + assertTrue(delegate.tryStartAddingAttachment()) + } + + @Test + fun tryStartAddingAttachment_whenAttachmentsReachLimit_returnsFalse() { + val delegate = loadedDelegate( + persistedDraft = draft( + attachments = listOf( + attachment(contentUri = "content://a/1"), + attachment(contentUri = "content://a/2"), + ), + ), + ) + givenAttachmentLimit(limit = 2) + + assertFalse(delegate.tryStartAddingAttachment()) + } + + @Test + fun tryStartAddingAttachment_countsPendingAttachmentsTowardLimit() { + val delegate = loadedDelegate( + persistedDraft = draft(attachments = listOf(attachment(contentUri = "content://a/1"))), + ) + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + givenAttachmentLimit(limit = 2) + + assertFalse(delegate.tryStartAddingAttachment()) + } + + @Test + fun addPendingAttachment_addsToVisiblePendingAttachments() { + val delegate = loadedDelegate() + val pending = pendingAttachment(pendingAttachmentId = "p1") + + delegate.addPendingAttachment(pendingAttachment = pending) + + assertEquals(listOf(pending), delegate.state.value.pendingAttachments) + } + + @Test + fun removeAttachment_removesOnlyMatchingAttachment() { + val delegate = loadedDelegate( + persistedDraft = draft( + attachments = listOf( + attachment(contentUri = "content://a/1"), + attachment(contentUri = "content://a/2"), + ), + ), + ) + + delegate.removeAttachment(contentUri = "content://a/1") + + assertEquals( + listOf("content://a/2"), + delegate.state.value.draft.attachments.map { attachment -> attachment.contentUri }, + ) + } + + @Test + fun removePendingAttachment_removesOnlyMatchingPendingAttachment() { + val delegate = loadedDelegate() + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p2"), + ) + + delegate.removePendingAttachment(pendingAttachmentId = "p1") + + assertEquals( + listOf("p2"), + delegate.state.value.pendingAttachments.map { pending -> pending.pendingAttachmentId }, + ) + } + + @Test + fun updateAttachmentCaption_updatesCaptionForMatchingAttachment() { + val delegate = loadedDelegate( + persistedDraft = draft( + attachments = listOf(attachment(contentUri = "content://a/1", captionText = "")), + ), + ) + + delegate.updateAttachmentCaption(contentUri = "content://a/1", captionText = "a caption") + + assertEquals("a caption", delegate.state.value.draft.attachments.single().captionText) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegatePendingResolutionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegatePendingResolutionTest.kt new file mode 100644 index 000000000..92d40c74c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegatePendingResolutionTest.kt @@ -0,0 +1,133 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.ui.conversation.composer.delegate.DraftPendingAttachmentResolution +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationDraftEditorDelegatePendingResolutionTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun resolvePendingAttachment_whenIdIsUnknown_returnsUnresolvedAndLeavesExistingPendingIntact() { + val delegate = loadedDelegate() + val existingPending = pendingAttachment(pendingAttachmentId = "p1") + delegate.addPendingAttachment(pendingAttachment = existingPending) + + val resolution = delegate.resolvePendingAttachment( + pendingAttachmentId = "missing", + attachment = attachment(contentUri = "content://a/1"), + ) + + assertEquals( + DraftPendingAttachmentResolution( + didResolveAttachment = false, + didDropAttachments = false, + ), + resolution, + ) + verify(exactly = 0) { + resolveDraftAttachmentsWithinLimit(currentAttachments = any(), attachmentsToAdd = any()) + } + assertEquals(listOf(existingPending), delegate.state.value.pendingAttachments) + assertTrue(delegate.state.value.draft.attachments.isEmpty()) + } + + @Test + fun resolvePendingAttachment_whenAccepted_removesPendingAddsAttachmentAndReportsResolved() { + val delegate = loadedDelegate() + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + val resolved = attachment(contentUri = "content://resolved") + givenAttachmentLimitResult(attachmentsToAdd = listOf(resolved), didDropAttachments = false) + + val resolution = delegate.resolvePendingAttachment( + pendingAttachmentId = "p1", + attachment = resolved, + ) + + assertEquals( + DraftPendingAttachmentResolution( + didResolveAttachment = true, + didDropAttachments = false, + ), + resolution, + ) + assertTrue(delegate.state.value.pendingAttachments.isEmpty()) + assertEquals(listOf(resolved), delegate.state.value.draft.attachments.toList()) + } + + @Test + fun resolvePendingAttachment_whenDroppedByLimit_removesPendingButDoesNotAddAttachment() { + val delegate = loadedDelegate() + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + givenAttachmentLimitResult(attachmentsToAdd = emptyList(), didDropAttachments = true) + + val resolution = delegate.resolvePendingAttachment( + pendingAttachmentId = "p1", + attachment = attachment(contentUri = "content://candidate"), + ) + + assertEquals( + DraftPendingAttachmentResolution( + didResolveAttachment = false, + didDropAttachments = true, + ), + resolution, + ) + assertTrue(delegate.state.value.pendingAttachments.isEmpty()) + assertTrue(delegate.state.value.draft.attachments.isEmpty()) + } + + @Test + fun resolvePendingAttachment_resolvesLimitAgainstCommittedAttachmentsAndCandidate() { + val delegate = loadedDelegate( + persistedDraft = draft(attachments = listOf(attachment(contentUri = "content://a/1"))), + ) + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + val resolved = attachment(contentUri = "content://resolved") + givenAttachmentLimitResult(attachmentsToAdd = listOf(resolved), didDropAttachments = false) + + delegate.resolvePendingAttachment(pendingAttachmentId = "p1", attachment = resolved) + + verify(exactly = 1) { + resolveDraftAttachmentsWithinLimit( + currentAttachments = match> { current -> + current.map { attachment -> attachment.contentUri } == listOf("content://a/1") + }, + attachmentsToAdd = listOf(resolved), + ) + } + } + + @Test + fun resolvePendingAttachment_resolvesLimitAgainstAttachmentsRemainingAfterIntermediateEdits() { + val delegate = loadedDelegate( + persistedDraft = draft(attachments = listOf(attachment(contentUri = "content://a/1"))), + ) + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + delegate.removeAttachment(contentUri = "content://a/1") + val resolved = attachment(contentUri = "content://resolved") + givenAttachmentLimitResult(attachmentsToAdd = listOf(resolved), didDropAttachments = false) + + delegate.resolvePendingAttachment(pendingAttachmentId = "p1", attachment = resolved) + + verify(exactly = 1) { + resolveDraftAttachmentsWithinLimit( + currentAttachments = match> { current -> + current.isEmpty() + }, + attachmentsToAdd = listOf(resolved), + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSaveTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSaveTest.kt new file mode 100644 index 000000000..3ddba2ccf --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSaveTest.kt @@ -0,0 +1,160 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import app.cash.turbine.test +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.delegate.DraftSaveRequest +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationDraftEditorDelegateSaveTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun currentSaveRequest_whenLoadedDraftBecomesDirty_reflectsEffectiveDraft() { + val delegate = loadedDelegate() + assertNull(delegate.currentSaveRequest) + + delegate.onMessageTextChanged(messageText = "draft text") + + assertEquals( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "draft text"), + ), + delegate.currentSaveRequest, + ) + } + + @Test + fun saveRequests_emitsNullThenSaveRequestAsDraftBecomesDirty() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = loadedDelegate() + + delegate.saveRequests.test { + assertNull(awaitItem()) + + delegate.onMessageTextChanged(messageText = "hello") + runCurrent() + + assertEquals( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello"), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun reset_returnsPendingSaveRequestAndClearsDraft() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "unsaved") + + val saveRequest = delegate.reset(conversationId = null) + + assertEquals( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "unsaved"), + ), + saveRequest, + ) + assertEquals(ConversationDraftState(), delegate.state.value) + assertNull(delegate.currentSaveRequest) + } + + @Test + fun reset_whenThereIsNothingToSave_returnsNull() { + val delegate = loadedDelegate() + + assertNull(delegate.reset(conversationId = null)) + } + + @Test + fun applyPersistedDraftUpdate_forDifferentConversation_isIgnored() { + val delegate = loadedDelegate(persistedDraft = draft(messageText = "persisted")) + + delegate.applyPersistedDraftUpdate( + persistedDraftUpdate = persistedDraftUpdate( + conversationId = "conversation-other", + persistedDraft = draft(messageText = "other text"), + ), + ) + + assertEquals("persisted", delegate.state.value.draft.messageText) + } + + @Test + fun applyPersistedDraftUpdate_forMatchingConversation_updatesPersistedDraftAndMarksLoaded() { + val delegate = createDelegate() + delegate.reset(conversationId = CONVERSATION_ID) + assertTrue(delegate.state.value.draft.isCheckingDraft) + + delegate.applyPersistedDraftUpdate( + persistedDraftUpdate = persistedDraftUpdate( + conversationId = CONVERSATION_ID, + persistedDraft = draft(messageText = "from server"), + ), + ) + + assertEquals("from server", delegate.state.value.draft.messageText) + assertFalse(delegate.state.value.draft.isCheckingDraft) + } + + @Test + fun matchesSaveRequest_reflectsWhetherRequestEqualsCurrentEffectiveDraft() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + assertTrue( + delegate.matchesSaveRequest( + saveRequest = DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + ), + ) + assertFalse( + delegate.matchesSaveRequest( + saveRequest = DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "different"), + ), + ), + ) + } + + @Test + fun applyPersistedSaveResult_clearsPendingEditsWhileKeepingVisibleDraft() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + assertEquals( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + delegate.currentSaveRequest, + ) + + delegate.applyPersistedSaveResult( + saveRequest = DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + ) + + assertNull(delegate.currentSaveRequest) + assertEquals("hi", delegate.state.value.draft.messageText) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSeedTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSeedTest.kt new file mode 100644 index 000000000..75a132f2d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSeedTest.kt @@ -0,0 +1,88 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ConversationDraftEditorDelegateSeedTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun seedDraft_whenConversationAlreadySet_appliesSeedImmediately() { + val delegate = createDelegate() + delegate.reset(conversationId = CONVERSATION_ID) + + delegate.seedDraft( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "seeded"), + ) + + assertEquals("seeded", delegate.state.value.draft.messageText) + } + + @Test + fun seedDraft_beforeConversationIsSet_isDeferredUntilMatchingReset() { + val delegate = createDelegate() + + delegate.seedDraft( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "seeded"), + ) + assertEquals("", delegate.state.value.draft.messageText) + + delegate.reset(conversationId = CONVERSATION_ID) + + assertEquals("seeded", delegate.state.value.draft.messageText) + } + + @Test + fun seedDraft_isNotAppliedToADifferentConversation() { + val delegate = createDelegate() + delegate.seedDraft( + conversationId = "conversation-seeded", + draft = draft(messageText = "seeded"), + ) + + delegate.reset(conversationId = "conversation-other") + assertEquals("", delegate.state.value.draft.messageText) + + delegate.reset(conversationId = "conversation-seeded") + assertEquals("seeded", delegate.state.value.draft.messageText) + } + + @Test + fun seedDraft_survivesInterleavedPersistedUpdateAndIsAppliedByLaterReset() { + val delegate = createDelegate() + delegate.seedDraft( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "seeded"), + ) + + delegate.applyPersistedDraftUpdate( + persistedDraftUpdate = persistedDraftUpdate( + conversationId = CONVERSATION_ID, + persistedDraft = draft(messageText = "persisted"), + ), + ) + assertEquals("", delegate.state.value.draft.messageText) + + delegate.reset(conversationId = CONVERSATION_ID) + + assertEquals("seeded", delegate.state.value.draft.messageText) + } + + @Test + fun seedDraft_isAppliedOnlyOnce() { + val delegate = createDelegate() + delegate.reset(conversationId = CONVERSATION_ID) + delegate.seedDraft( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "seeded"), + ) + assertEquals("seeded", delegate.state.value.draft.messageText) + + delegate.reset(conversationId = CONVERSATION_ID) + + assertEquals("", delegate.state.value.draft.messageText) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendLifecycleTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendLifecycleTest.kt new file mode 100644 index 000000000..efdfea359 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendLifecycleTest.kt @@ -0,0 +1,160 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.delegate.DraftSendRequest +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 ConversationDraftEditorDelegateSendLifecycleTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun createSendRequestOrNull_whenConversationNotSet_returnsNull() { + val delegate = createDelegate() + + assertNull(delegate.createSendRequestOrNull()) + } + + @Test + fun createSendRequestOrNull_whenDraftHasNoContent_returnsNull() { + val delegate = loadedDelegate() + + assertNull(delegate.createSendRequestOrNull()) + } + + @Test + fun createSendRequestOrNull_whenPendingAttachmentsRemain_returnsNull() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + + assertNull(delegate.createSendRequestOrNull()) + } + + @Test + fun createSendRequestOrNull_whenSendable_returnsRequestWithEffectiveDraft() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + assertEquals( + DraftSendRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + delegate.createSendRequestOrNull(), + ) + } + + @Test + fun markSendingForSendRequest_forMatchingConversation_marksSendingAndReturnsTrue() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + val didMarkSending = delegate.markSendingForSendRequest( + sendRequest = DraftSendRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + ) + + assertTrue(didMarkSending) + assertTrue(delegate.state.value.draft.isSending) + } + + @Test + fun markSendingForSendRequest_forDifferentConversation_returnsFalseWithoutMarking() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + val didMarkSending = delegate.markSendingForSendRequest( + sendRequest = DraftSendRequest( + conversationId = "conversation-other", + draft = draft(messageText = "hi"), + ), + ) + + assertFalse(didMarkSending) + assertFalse(delegate.state.value.draft.isSending) + } + + @Test + fun markSendingForSendRequest_whenAlreadySending_returnsFalse() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + val sendRequest = DraftSendRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ) + assertTrue(delegate.markSendingForSendRequest(sendRequest = sendRequest)) + + assertFalse(delegate.markSendingForSendRequest(sendRequest = sendRequest)) + } + + @Test + fun markConversationDraftAsIdle_forMatchingConversation_clearsSending() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + delegate.markSendingForSendRequest( + sendRequest = DraftSendRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + ) + assertTrue(delegate.state.value.draft.isSending) + + delegate.markConversationDraftAsIdle(conversationId = CONVERSATION_ID) + + assertFalse(delegate.state.value.draft.isSending) + } + + @Test + fun markConversationDraftAsIdle_forDifferentConversation_isIgnored() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + delegate.markSendingForSendRequest( + sendRequest = DraftSendRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + ) + + delegate.markConversationDraftAsIdle(conversationId = "conversation-other") + + assertTrue(delegate.state.value.draft.isSending) + } + + @Test + fun clearConversationDraftAfterSend_forMatchingConversation_clearsDraftContent() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + delegate.clearConversationDraftAfterSend( + sendRequest = DraftSendRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ), + ) + + assertEquals("", delegate.state.value.draft.messageText) + } + + @Test + fun clearConversationDraftAfterSend_forDifferentConversation_isIgnored() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + delegate.clearConversationDraftAfterSend( + sendRequest = DraftSendRequest( + conversationId = "conversation-other", + draft = draft(messageText = "hi"), + ), + ) + + assertEquals("hi", delegate.state.value.draft.messageText) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendProtocolUpdatesTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendProtocolUpdatesTest.kt new file mode 100644 index 000000000..88531c25a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateSendProtocolUpdatesTest.kt @@ -0,0 +1,182 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import app.cash.turbine.test +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.coEvery +import io.mockk.coVerify +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationDraftEditorDelegateSendProtocolUpdatesTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun applySendProtocol_whenDraftHasContent_appliesGivenProtocol() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + + delegate.applySendProtocol(sendProtocol = ConversationDraftSendProtocol.MMS) + + assertEquals(ConversationDraftSendProtocol.MMS, delegate.state.value.sendProtocol) + } + + @Test + fun applySendProtocol_whenDraftIsEmpty_forcesSms() { + val delegate = loadedDelegate() + + delegate.applySendProtocol(sendProtocol = ConversationDraftSendProtocol.MMS) + + assertEquals(ConversationDraftSendProtocol.SMS, delegate.state.value.sendProtocol) + } + + @Test + fun sendProtocolUpdates_resolvesProtocolWithConversationAndDraftAfterDebounce() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolvedSendProtocol(protocol = ConversationDraftSendProtocol.MMS) + val delegate = loadedDelegate() + + delegate.sendProtocolUpdates.test { + delegate.onMessageTextChanged(messageText = "hi") + advanceTimeBy(249.milliseconds) + expectNoEvents() + coVerify(exactly = 0) { + resolveConversationDraftSendProtocol(conversationId = any(), draft = any()) + } + + advanceUntilIdle() + + assertEquals(ConversationDraftSendProtocol.MMS, awaitItem()) + coVerify(exactly = 1) { + resolveConversationDraftSendProtocol( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi"), + ) + } + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun sendProtocolUpdates_ignoresChangesThatDoNotAffectConversationOrDraft() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolvedSendProtocol(protocol = ConversationDraftSendProtocol.MMS) + val delegate = loadedDelegate() + + delegate.sendProtocolUpdates.test { + delegate.onMessageTextChanged(messageText = "hi") + advanceUntilIdle() + assertEquals(ConversationDraftSendProtocol.MMS, awaitItem()) + + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + advanceUntilIdle() + + expectNoEvents() + coVerify(exactly = 1) { + resolveConversationDraftSendProtocol(conversationId = any(), draft = any()) + } + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun sendProtocolUpdates_reResolvesAndReEmitsWhenPendingAttachmentIsResolvedIntoDraft() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolvedSendProtocol(protocol = ConversationDraftSendProtocol.SMS) + coEvery { + resolveConversationDraftSendProtocol( + conversationId = any(), + draft = match { draft -> draft.attachments.isNotEmpty() }, + ) + } returns ConversationDraftSendProtocol.MMS + val delegate = loadedDelegate() + + delegate.sendProtocolUpdates.test { + delegate.onMessageTextChanged(messageText = "hi") + advanceUntilIdle() + assertEquals(ConversationDraftSendProtocol.SMS, awaitItem()) + + delegate.addPendingAttachment( + pendingAttachment = pendingAttachment(pendingAttachmentId = "p1"), + ) + val resolved = attachment(contentUri = "content://resolved") + givenAttachmentLimitResult( + attachmentsToAdd = listOf(resolved), + didDropAttachments = false, + ) + delegate.resolvePendingAttachment(pendingAttachmentId = "p1", attachment = resolved) + advanceUntilIdle() + + assertEquals(ConversationDraftSendProtocol.MMS, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun sendProtocolUpdates_doesNotReEmitWhenResolvedProtocolIsUnchanged() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolvedSendProtocol(protocol = ConversationDraftSendProtocol.SMS) + val delegate = loadedDelegate() + + delegate.sendProtocolUpdates.test { + delegate.onMessageTextChanged(messageText = "hi") + advanceUntilIdle() + assertEquals(ConversationDraftSendProtocol.SMS, awaitItem()) + + delegate.onMessageTextChanged(messageText = "hello") + advanceUntilIdle() + + expectNoEvents() + coVerify(exactly = 2) { + resolveConversationDraftSendProtocol(conversationId = any(), draft = any()) + } + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun sendProtocolUpdates_cancelsInFlightResolutionWhenDraftChangesAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = loadedDelegate() + coEvery { + resolveConversationDraftSendProtocol( + conversationId = any(), + draft = draft(messageText = "slow"), + ) + } coAnswers { + delay(timeMillis = 1_000) + ConversationDraftSendProtocol.MMS + } + coEvery { + resolveConversationDraftSendProtocol( + conversationId = any(), + draft = draft(messageText = "fast"), + ) + } returns ConversationDraftSendProtocol.SMS + + delegate.sendProtocolUpdates.test { + delegate.onMessageTextChanged(messageText = "slow") + advanceTimeBy(300.milliseconds) + delegate.onMessageTextChanged(messageText = "fast") + advanceUntilIdle() + + assertEquals(ConversationDraftSendProtocol.SMS, awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt new file mode 100644 index 000000000..017797ad9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate + +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationDraftEditorDelegateStateProjectionTest : + BaseConversationDraftEditorDelegateTest() { + + @Test + fun onMessageTextChanged_reflectsTextInVisibleStateAndKeepsSmsProtocol() { + val delegate = loadedDelegate() + + delegate.onMessageTextChanged(messageText = "hello") + + assertEquals("hello", delegate.state.value.draft.messageText) + assertEquals(ConversationDraftSendProtocol.SMS, delegate.state.value.sendProtocol) + } + + @Test + fun onSubjectTextChanged_makesDraftMmsAndResolvesVisibleProtocolToMms() { + val delegate = loadedDelegate() + + delegate.onSubjectTextChanged(subjectText = "subject") + + assertEquals("subject", delegate.state.value.draft.subjectText) + assertEquals(ConversationDraftSendProtocol.MMS, delegate.state.value.sendProtocol) + } + + @Test + fun onSelfParticipantIdChanged_reflectsParticipantWithoutPromotingToMms() { + val delegate = loadedDelegate() + + delegate.onSelfParticipantIdChanged(selfParticipantId = "self-2") + + assertEquals("self-2", delegate.state.value.draft.selfParticipantId) + assertEquals(ConversationDraftSendProtocol.SMS, delegate.state.value.sendProtocol) + } + + @Test + fun removingLastAttachment_emptiesDraftAndResetsProtocolToSms() { + val delegate = loadedDelegate( + persistedDraft = draft(attachments = listOf(attachment(contentUri = "content://a/1"))), + ) + assertEquals(ConversationDraftSendProtocol.MMS, delegate.state.value.sendProtocol) + + delegate.removeAttachment(contentUri = "content://a/1") + + assertTrue(delegate.state.value.draft.attachments.isEmpty()) + assertEquals(ConversationDraftSendProtocol.SMS, delegate.state.value.sendProtocol) + } + + @Test + fun clearingSubjectWhileTextRemains_downgradesMmsToSms() { + val delegate = loadedDelegate( + persistedDraft = draft(messageText = "hi", subjectText = "subject"), + ) + assertEquals(ConversationDraftSendProtocol.MMS, delegate.state.value.sendProtocol) + + delegate.onSubjectTextChanged(subjectText = "") + + assertEquals(ConversationDraftSendProtocol.SMS, delegate.state.value.sendProtocol) + } + + @Test + fun textEditAfterAppliedSendProtocol_preservesResolvedProtocolForTextDraft() { + val delegate = loadedDelegate() + delegate.onMessageTextChanged(messageText = "hi") + delegate.applySendProtocol(sendProtocol = ConversationDraftSendProtocol.MMS) + assertEquals(ConversationDraftSendProtocol.MMS, delegate.state.value.sendProtocol) + + delegate.onMessageTextChanged(messageText = "hi there") + + assertEquals(ConversationDraftSendProtocol.MMS, delegate.state.value.sendProtocol) + } + + @Test + fun reset_marksVisibleDraftAsCheckingUntilPersistedDraftArrives() { + val delegate = createDelegate() + + delegate.reset(conversationId = "conversation-loading") + assertTrue(delegate.state.value.draft.isCheckingDraft) + + delegate.applyPersistedDraftUpdate( + persistedDraftUpdate = persistedDraftUpdate( + conversationId = "conversation-loading", + ), + ) + assertFalse(delegate.state.value.draft.isCheckingDraft) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/BaseConversationDraftDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/BaseConversationDraftDelegateTest.kt new file mode 100644 index 000000000..0354529c4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/BaseConversationDraftDelegateTest.kt @@ -0,0 +1,232 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.data.conversation.repository.ConversationDraftsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocolImpl +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimitImpl +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftEditorDelegateImpl +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationDraftDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected suspend fun TestScope.createBoundLoadedDelegateHarness( + sendConversationDraft: SendConversationDraft = createSendConversationDraftMock(), + actionRequirements: CheckConversationActionRequirements = createActionRequirementsMock(), + conversationsRepository: ConversationsRepository = createConversationsRepositoryMock(), + getDraftSendProtocol: GetConversationDraftSendProtocol = createGetDraftSendProtocolMock(), + ): DelegateHarness { + val harness = createHarness( + sendConversationDraft = sendConversationDraft, + actionRequirements = actionRequirements, + conversationsRepository = conversationsRepository, + getDraftSendProtocol = getDraftSendProtocol, + ) + harness.conversationIdFlow.value = CONVERSATION_ID + harness.emitDraft( + conversationId = CONVERSATION_ID, + draft = ConversationDraft(), + ) + advanceUntilIdle() + + return harness + } + + protected fun TestScope.createHarness( + sendConversationDraft: SendConversationDraft = createSendConversationDraftMock(), + actionRequirements: CheckConversationActionRequirements = createActionRequirementsMock(), + conversationsRepository: ConversationsRepository = createConversationsRepositoryMock(), + getDraftSendProtocol: GetConversationDraftSendProtocol = createGetDraftSendProtocolMock(), + observeFailure: Exception? = null, + ): DelegateHarness { + val dispatcher = mainDispatcherRule.testDispatcher + val applicationScope = TestScope(dispatcher) + val delegateScope = TestScope(dispatcher) + val draftFlows = mutableMapOf>() + val conversationDraftsRepository = createConversationDraftsRepositoryMock( + draftFlows = draftFlows, + observeFailure = observeFailure, + ) + val subscriptionsRepository = createSubscriptionsRepositoryMock() + val conversationDraftEditorDelegate = ConversationDraftEditorDelegateImpl( + subscriptionsRepository = subscriptionsRepository, + resolveConversationDraftSendProtocol = ResolveConversationDraftSendProtocolImpl( + conversationsRepository = conversationsRepository, + getConversationDraftSendProtocol = getDraftSendProtocol, + ), + resolveDraftAttachmentsWithinLimit = ResolveDraftAttachmentsWithinLimitImpl( + subscriptionsRepository = subscriptionsRepository, + ), + ) + val delegate = ConversationDraftDelegateImpl( + applicationScope = applicationScope, + checkConversationActionRequirements = actionRequirements, + conversationDraftsRepository = conversationDraftsRepository, + conversationDraftEditorDelegate = conversationDraftEditorDelegate, + sendConversationDraft = sendConversationDraft, + defaultDispatcher = dispatcher, + ) + val conversationIdFlow = MutableStateFlow(null) + + delegate.bind( + scope = delegateScope, + conversationIdFlow = conversationIdFlow, + ) + + return DelegateHarness( + delegate = delegate, + conversationDraftsRepository = conversationDraftsRepository, + draftFlows = draftFlows, + conversationIdFlow = conversationIdFlow, + delegateScope = delegateScope, + applicationScope = applicationScope, + ) + } + + protected fun createActionRequirementsMock( + initialResult: ConversationActionRequirementsResult = + ConversationActionRequirementsResult.Ready, + ): CheckConversationActionRequirements { + return createActionRequirementsMock(results = listOf(initialResult)) + } + + protected fun createActionRequirementsMock( + results: List, + ): CheckConversationActionRequirements { + val mock = mockk() + every { mock.invoke() } returnsMany results + return mock + } + + protected fun createConversationDraftsRepositoryMock( + draftFlows: MutableMap>, + observeFailure: Exception? = null, + ): ConversationDraftsRepository { + val repository = mockk() + every { repository.observeConversationDraft(conversationId = any()) } answers { + observeFailure?.let { exception -> + return@answers flow { + throw exception + } + } + draftFlows.getOrPut(firstArg()) { + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 16, + ) + } + } + coEvery { + repository.saveDraft( + conversationId = any(), + draft = any(), + ) + } just runs + return repository + } + + protected fun createConversationsRepositoryMock( + sendData: ConversationSendData? = mockk(relaxed = true), + ): ConversationsRepository { + val repository = mockk(relaxed = true) + coEvery { + repository.getConversationSendData( + conversationId = any(), + requestedSelfParticipantId = any(), + ) + } returns sendData + return repository + } + + protected fun createSubscriptionsRepositoryMock(): SubscriptionsRepository { + val repository = mockk(relaxed = true) + every { repository.resolveAttachmentLimit() } returns Int.MAX_VALUE + return repository + } + + protected fun createGetDraftSendProtocolMock( + protocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, + ): GetConversationDraftSendProtocol { + val mock = mockk() + every { + mock.invoke( + draft = any(), + sendData = any(), + ) + } returns protocol + return mock + } + + protected fun createSendConversationDraftMock( + sendResult: Flow = createSuccessfulSendFlow(), + ): SendConversationDraft { + val mock = mockk() + every { + mock.invoke( + conversationId = any(), + draft = any(), + ignoreMessageSizeLimit = any(), + ) + } returns sendResult + return mock + } + + protected fun createSuccessfulSendFlow(): Flow { + return flow { + emit(Unit) + } + } + + protected data class DelegateHarness( + val delegate: ConversationDraftDelegateImpl, + val conversationDraftsRepository: ConversationDraftsRepository, + val draftFlows: MutableMap>, + val conversationIdFlow: MutableStateFlow, + val delegateScope: TestScope, + val applicationScope: TestScope, + ) { + suspend fun emitDraft( + conversationId: String, + draft: ConversationDraft, + ) { + draftFlows.getOrPut(conversationId) { + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 16, + ) + }.emit(draft) + } + + fun cancel() { + delegateScope.cancel() + applicationScope.cancel() + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateActionRequirementsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateActionRequirementsTest.kt new file mode 100644 index 000000000..7889b7e2b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateActionRequirementsTest.kt @@ -0,0 +1,212 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import android.app.Activity +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationDraftDelegateActionRequirementsTest : + BaseConversationDraftDelegateTest() { + + @Test + fun onSendClick_whenSmsIsNotCapable_emitsSmsDisabledMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createBoundLoadedDelegateHarness( + actionRequirements = createActionRequirementsMock( + initialResult = ConversationActionRequirementsResult.SmsNotCapable, + ), + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + harness.delegate.effects.test { + harness.delegate.onSendClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onSendClick_whenPreferredSmsSimIsMissing_emitsNoPreferredSimMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createBoundLoadedDelegateHarness( + actionRequirements = createActionRequirementsMock( + initialResult = ConversationActionRequirementsResult.NoPreferredSmsSim, + ), + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + harness.delegate.effects.test { + harness.delegate.onSendClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onSendClick_whenDefaultSmsRoleIsMissing_promptsAndSendsAfterRoleRequestSucceeds() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = createSendConversationDraftMock() + val actionRequirements = createActionRequirementsMock( + results = listOf( + ConversationActionRequirementsResult.MissingDefaultSmsRole, + ConversationActionRequirementsResult.Ready, + ), + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + actionRequirements = actionRequirements, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + harness.delegate.effects.test { + harness.delegate.onSendClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.RequestDefaultSmsRole(isSending = true), + awaitItem(), + ) + verify(exactly = 0) { + @Suppress("UnusedFlow") + sendConversationDraft.invoke( + conversationId = any(), + draft = any(), + ignoreMessageSizeLimit = any(), + ) + } + + assertTrue( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ), + ) + advanceUntilIdle() + + val sentDraft = slot() + verify(exactly = 1) { + @Suppress("UnusedFlow") + sendConversationDraft.invoke( + conversationId = any(), + draft = capture(sentDraft), + ignoreMessageSizeLimit = any(), + ) + } + assertEquals("Hello", sentDraft.captured.messageText) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_withoutPendingSend_returnsFalse() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createBoundLoadedDelegateHarness() + + try { + assertFalse( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ), + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_whenCanceled_clearsPendingSendWithoutSending() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = createSendConversationDraftMock() + val actionRequirements = createActionRequirementsMock( + initialResult = ConversationActionRequirementsResult.MissingDefaultSmsRole, + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + actionRequirements = actionRequirements, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + harness.delegate.effects.test { + harness.delegate.onSendClick() + advanceUntilIdle() + awaitItem() + + assertFalse( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_CANCELED, + ), + ) + advanceUntilIdle() + + assertFalse( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ), + ) + advanceUntilIdle() + + verify(exactly = 0) { + @Suppress("UnusedFlow") + sendConversationDraft.invoke( + conversationId = any(), + draft = any(), + ignoreMessageSizeLimit = any(), + ) + } + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateAutosaveTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateAutosaveTest.kt new file mode 100644 index 000000000..f2febdb2d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateAutosaveTest.kt @@ -0,0 +1,97 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.runs +import io.mockk.slot +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationDraftDelegateAutosaveTest : BaseConversationDraftDelegateTest() { + + @Test + fun onMessageTextChanged_autosavesAfterDebounce() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createBoundLoadedDelegateHarness() + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + advanceTimeBy(299.milliseconds) + coVerify(exactly = 0) { + harness.conversationDraftsRepository.saveDraft( + conversationId = any(), + draft = any(), + ) + } + + advanceTimeBy(1.milliseconds) + advanceUntilIdle() + + val savedDraft = slot() + coVerify(exactly = 1) { + harness.conversationDraftsRepository.saveDraft( + conversationId = CONVERSATION_ID, + draft = capture(savedDraft), + ) + } + assertEquals("Hello", savedDraft.captured.messageText) + } finally { + harness.cancel() + } + } + } + + @Test + fun persistDraft_catchesSaveFailuresAndLeavesStateUsable() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createBoundLoadedDelegateHarness() + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + coEvery { + harness.conversationDraftsRepository.saveDraft( + conversationId = any(), + draft = any(), + ) + } throws IllegalStateException("boom") + + harness.delegate.persistDraft() + advanceUntilIdle() + + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + coVerify { + harness.conversationDraftsRepository.saveDraft( + conversationId = CONVERSATION_ID, + draft = match { draft -> draft.messageText == "Hello" }, + ) + } + + coEvery { + harness.conversationDraftsRepository.saveDraft( + conversationId = any(), + draft = any(), + ) + } just runs + harness.delegate.persistDraft() + advanceUntilIdle() + + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateObservationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateObservationTest.kt new file mode 100644 index 000000000..a4a4f87ac --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateObservationTest.kt @@ -0,0 +1,99 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import io.mockk.coVerify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationDraftDelegateObservationTest : BaseConversationDraftDelegateTest() { + + @Test + fun bind_setsCheckingStateUntilPersistedDraftArrives() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + try { + harness.conversationIdFlow.value = CONVERSATION_ID + advanceUntilIdle() + + assertTrue(harness.delegate.state.value.draft.isCheckingDraft) + + harness.emitDraft( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Persisted", + ), + ) + advanceUntilIdle() + + assertEquals("Persisted", harness.delegate.state.value.draft.messageText) + assertFalse(harness.delegate.state.value.draft.isCheckingDraft) + } finally { + harness.cancel() + } + } + } + + @Test + fun bind_catchesObservationFailuresAndPublishesSafeEmptyDraft() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness( + observeFailure = IllegalStateException("boom"), + ) + try { + harness.conversationIdFlow.value = CONVERSATION_ID + advanceUntilIdle() + + assertEquals( + ConversationDraftState( + draft = ConversationDraft(), + ), + harness.delegate.state.value, + ) + assertFalse(harness.delegate.state.value.draft.isCheckingDraft) + } finally { + harness.cancel() + } + } + } + + @Test + fun conversationSwitch_flushesPreviousDraftBeforeResettingState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + try { + harness.conversationIdFlow.value = CONVERSATION_ID + harness.emitDraft( + conversationId = CONVERSATION_ID, + draft = ConversationDraft(), + ) + advanceUntilIdle() + + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.conversationIdFlow.value = "conversation-2" + advanceUntilIdle() + + coVerify(exactly = 1) { + harness.conversationDraftsRepository.saveDraft( + conversationId = CONVERSATION_ID, + draft = any(), + ) + } + assertTrue(harness.delegate.state.value.draft.isCheckingDraft) + assertEquals("", harness.delegate.state.value.draft.messageText) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendProtocolTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendProtocolTest.kt new file mode 100644 index 000000000..6fbdc8873 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendProtocolTest.kt @@ -0,0 +1,208 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.coVerify +import io.mockk.verify +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationDraftDelegateSendProtocolTest : BaseConversationDraftDelegateTest() { + + @Test + fun onMessageTextChanged_resolvesDraftSendProtocolAfterDebounce() { + runTest(context = mainDispatcherRule.testDispatcher) { + val conversationsRepository = createConversationsRepositoryMock() + val getDraftSendProtocol = createGetDraftSendProtocolMock( + protocol = ConversationDraftSendProtocol.MMS, + ) + val harness = createBoundLoadedDelegateHarness( + conversationsRepository = conversationsRepository, + getDraftSendProtocol = getDraftSendProtocol, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + advanceTimeBy(249.milliseconds) + + coVerify(exactly = 0) { + conversationsRepository.getConversationSendData( + conversationId = any(), + requestedSelfParticipantId = any(), + ) + } + assertEquals( + ConversationDraftSendProtocol.SMS, + harness.delegate.state.value.sendProtocol, + ) + + advanceTimeBy(1.milliseconds) + advanceUntilIdle() + + coVerify(exactly = 1) { + conversationsRepository.getConversationSendData( + conversationId = CONVERSATION_ID, + requestedSelfParticipantId = "", + ) + } + verify(exactly = 1) { + getDraftSendProtocol.invoke( + draft = match { draft -> draft.messageText == "Hello" }, + sendData = any(), + ) + } + assertEquals( + ConversationDraftSendProtocol.MMS, + harness.delegate.state.value.sendProtocol, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onMessageTextChanged_debouncesDraftSendProtocolUntilTypingSettles() { + runTest(context = mainDispatcherRule.testDispatcher) { + val conversationsRepository = createConversationsRepositoryMock() + val getDraftSendProtocol = createGetDraftSendProtocolMock( + protocol = ConversationDraftSendProtocol.MMS, + ) + val harness = createBoundLoadedDelegateHarness( + conversationsRepository = conversationsRepository, + getDraftSendProtocol = getDraftSendProtocol, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "H") + advanceTimeBy(100.milliseconds) + harness.delegate.onMessageTextChanged(messageText = "He") + advanceTimeBy(100.milliseconds) + harness.delegate.onMessageTextChanged(messageText = "Hel") + advanceTimeBy(249.milliseconds) + + coVerify(exactly = 0) { + conversationsRepository.getConversationSendData( + conversationId = any(), + requestedSelfParticipantId = any(), + ) + } + verify(exactly = 0) { + getDraftSendProtocol.invoke(draft = any(), sendData = any()) + } + + advanceTimeBy(1.milliseconds) + advanceUntilIdle() + + coVerify(exactly = 1) { + conversationsRepository.getConversationSendData( + conversationId = any(), + requestedSelfParticipantId = any(), + ) + } + verify(exactly = 1) { + getDraftSendProtocol.invoke( + draft = match { draft -> draft.messageText == "Hel" }, + sendData = any(), + ) + } + assertEquals( + ConversationDraftSendProtocol.MMS, + harness.delegate.state.value.sendProtocol, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onMessageTextChanged_whenDraftBecomesEmpty_resetsDraftSendProtocolToSms() { + runTest(context = mainDispatcherRule.testDispatcher) { + val conversationsRepository = createConversationsRepositoryMock() + val getDraftSendProtocol = createGetDraftSendProtocolMock( + protocol = ConversationDraftSendProtocol.MMS, + ) + val harness = createBoundLoadedDelegateHarness( + conversationsRepository = conversationsRepository, + getDraftSendProtocol = getDraftSendProtocol, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + advanceTimeBy(250.milliseconds) + advanceUntilIdle() + + assertEquals( + ConversationDraftSendProtocol.MMS, + harness.delegate.state.value.sendProtocol, + ) + + harness.delegate.onMessageTextChanged(messageText = "") + + assertEquals( + ConversationDraftSendProtocol.SMS, + harness.delegate.state.value.sendProtocol, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun addAttachments_whenSendDataIsUnavailable_fallsBackToMmsDraftProtocol() { + runTest(context = mainDispatcherRule.testDispatcher) { + val conversationsRepository = createConversationsRepositoryMock( + sendData = null, + ) + val getDraftSendProtocol = createGetDraftSendProtocolMock( + protocol = ConversationDraftSendProtocol.SMS, + ) + val harness = createBoundLoadedDelegateHarness( + conversationsRepository = conversationsRepository, + getDraftSendProtocol = getDraftSendProtocol, + ) + + try { + harness.delegate.addAttachments( + attachments = listOf( + ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://images/1", + ), + ), + ) + advanceTimeBy(250.milliseconds) + advanceUntilIdle() + + coVerify(exactly = 1) { + conversationsRepository.getConversationSendData( + conversationId = any(), + requestedSelfParticipantId = any(), + ) + } + verify(exactly = 0) { + getDraftSendProtocol.invoke(draft = any(), sendData = any()) + } + assertEquals( + ConversationDraftSendProtocol.MMS, + harness.delegate.state.value.sendProtocol, + ) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendTest.kt new file mode 100644 index 000000000..36741dd3c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendTest.kt @@ -0,0 +1,174 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.coVerify +import io.mockk.slot +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationDraftDelegateSendTest : BaseConversationDraftDelegateTest() { + + @Test + fun sendSuccess_allowsAutosavingNewDraftBeforeRepositoryClearsSentDraft() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createBoundLoadedDelegateHarness() + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("", harness.delegate.state.value.draft.messageText) + + harness.delegate.onMessageTextChanged(messageText = "Next") + advanceTimeBy(300.milliseconds) + advanceUntilIdle() + + val savedDraft = slot() + coVerify(exactly = 1) { + harness.conversationDraftsRepository.saveDraft( + conversationId = CONVERSATION_ID, + draft = capture(savedDraft), + ) + } + assertEquals("Next", savedDraft.captured.messageText) + } finally { + harness.cancel() + } + } + } + + @Test + fun sendFailure_restoresIdleStateAndKeepsDraft() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = createSendConversationDraftMock( + sendResult = flow { + throw IllegalStateException("boom") + }, + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + } finally { + harness.cancel() + } + } + } + + @Test + fun sendCancellation_restoresIdleState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = createSendConversationDraftMock( + sendResult = flow { + throw CancellationException("cancelled") + }, + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + } finally { + harness.cancel() + } + } + } + + @Test + fun typingDuringSendIsPreservedWhenDispatchCompletes() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendGate = CompletableDeferred() + val sendConversationDraft = createSendConversationDraftMock( + sendResult = flow { + sendGate.await() + emit(Unit) + }, + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + assertTrue(harness.delegate.state.value.draft.isSending) + + harness.delegate.onMessageTextChanged(messageText = "Next") + sendGate.complete(Unit) + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Next", harness.delegate.state.value.draft.messageText) + + advanceTimeBy(300.milliseconds) + advanceUntilIdle() + + val savedDraft = slot() + coVerify(exactly = 1) { + harness.conversationDraftsRepository.saveDraft( + conversationId = CONVERSATION_ID, + draft = capture(savedDraft), + ) + } + assertEquals("Next", savedDraft.captured.messageText) + } finally { + harness.cancel() + } + } + } + + @Test + fun sendFlowCompletingWithoutEmissionRestoresIdleState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = createSendConversationDraftMock( + sendResult = emptyFlow(), + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt new file mode 100644 index 000000000..55fc82341 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt @@ -0,0 +1,204 @@ +package com.android.messaging.ui.conversation.composer.delegate.draft + +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException +import com.android.messaging.domain.conversation.usecase.draft.exception.DraftDispatchFailedException +import com.android.messaging.domain.conversation.usecase.draft.exception.MessageLimitExceededException +import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException +import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationDraftDelegateSendValidationTest : BaseConversationDraftDelegateTest() { + + @Test + fun sendValidationFailure_whenRecipientIsUnknown_emitsUnknownSenderMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + assertSendFailureMessage( + exception = UnknownConversationRecipientException( + conversationId = CONVERSATION_ID, + ), + expectedMessageResId = R.string.unknown_sender, + ) + } + } + + @Test + fun sendValidationFailure_whenSimIsNotReady_emitsNetworkNotReadyMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + assertSendFailureMessage( + exception = ConversationSimNotReadyException( + conversationId = CONVERSATION_ID, + selfSubId = 1, + cause = IllegalStateException("SIM unavailable"), + ), + expectedMessageResId = R.string.cant_send_message_without_active_subscription, + ) + } + } + + @Test + fun sendValidationFailure_whenTooManyVideos_setsVideoLimitWarning() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = createSendConversationDraftMock( + sendResult = flow { + throw TooManyVideoAttachmentsException( + conversationId = CONVERSATION_ID, + videoAttachmentCount = 2, + ) + }, + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + assertEquals( + ConversationAttachmentLimitWarning.SendingVideoAttachmentLimitReached, + harness.delegate.attachmentLimitWarning.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun sendValidationFailure_whenMessageLimitExceeded_sendsAnywayWithLimitIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sendConversationDraft = mockk() + every { + sendConversationDraft.invoke( + conversationId = any(), + draft = any(), + ignoreMessageSizeLimit = false, + ) + } returns flow { + throw MessageLimitExceededException( + conversationId = CONVERSATION_ID, + ) + } + every { + sendConversationDraft.invoke( + conversationId = any(), + draft = any(), + ignoreMessageSizeLimit = true, + ) + } returns createSuccessfulSendFlow() + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + assertEquals( + ConversationAttachmentLimitWarning.SendingMessageLimitReached, + harness.delegate.attachmentLimitWarning.value, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + sendConversationDraft.invoke( + conversationId = any(), + draft = any(), + ignoreMessageSizeLimit = false, + ) + } + + harness.delegate.sendAnywayAfterAttachmentLimitWarning() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("", harness.delegate.state.value.draft.messageText) + assertNull(harness.delegate.attachmentLimitWarning.value) + verify(exactly = 1) { + @Suppress("UnusedFlow") + sendConversationDraft.invoke( + conversationId = any(), + draft = match { draft -> draft.messageText == "Hello" }, + ignoreMessageSizeLimit = true, + ) + } + } finally { + harness.cancel() + } + } + } + + @Test + fun sendValidationFailure_whenDispatchFails_emitsGenericSendFailureMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + assertSendFailureMessage( + exception = DraftDispatchFailedException( + conversationId = CONVERSATION_ID, + cause = IllegalStateException("boom"), + ), + expectedMessageResId = R.string.send_message_failure, + ) + } + } + + private suspend fun TestScope.assertSendFailureMessage( + exception: Throwable, + expectedMessageResId: Int, + ) { + val sendConversationDraft = createSendConversationDraftMock( + sendResult = flow { + throw exception + }, + ) + val harness = createBoundLoadedDelegateHarness( + sendConversationDraft = sendConversationDraft, + ) + + try { + harness.delegate.onMessageTextChanged(messageText = "Hello") + + harness.delegate.effects.test { + harness.delegate.onSendClick() + advanceUntilIdle() + + assertFalse(harness.delegate.state.value.draft.isSending) + assertEquals("Hello", harness.delegate.state.value.draft.messageText) + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = expectedMessageResId, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/BaseDraftEditorStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/BaseDraftEditorStateTest.kt new file mode 100644 index 000000000..958a4ad71 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/BaseDraftEditorStateTest.kt @@ -0,0 +1,85 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import kotlinx.collections.immutable.toImmutableList + +internal abstract class BaseDraftEditorStateTest { + + protected fun draft( + messageText: String = "", + subjectText: String = "", + selfParticipantId: String = "", + attachments: List = emptyList(), + isCheckingDraft: Boolean = false, + isSending: Boolean = false, + ): ConversationDraft { + return ConversationDraft( + messageText = messageText, + subjectText = subjectText, + selfParticipantId = selfParticipantId, + attachments = attachments.toImmutableList(), + isCheckingDraft = isCheckingDraft, + isSending = isSending, + ) + } + + protected fun attachment( + contentUri: String, + contentType: String = "image/jpeg", + captionText: String = "", + width: Int? = null, + height: Int? = null, + durationMillis: Long? = null, + ): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = captionText, + width = width, + height = height, + durationMillis = durationMillis, + ) + } + + protected fun pendingAttachment( + pendingAttachmentId: String, + contentUri: String = "content://pending/$pendingAttachmentId", + contentType: String = "image/jpeg", + displayName: String = "", + kind: ConversationDraftPendingAttachmentKind = + ConversationDraftPendingAttachmentKind.Generic, + ): ConversationDraftPendingAttachment { + return ConversationDraftPendingAttachment( + pendingAttachmentId = pendingAttachmentId, + contentUri = contentUri, + contentType = contentType, + displayName = displayName, + kind = kind, + ) + } + + protected fun loadedState( + conversationId: String? = CONVERSATION_ID, + persistedDraft: ConversationDraft = draft(), + isSending: Boolean = false, + pendingAttachments: List = emptyList(), + pendingSentDraft: ConversationDraft? = null, + ): DraftEditorState { + return DraftEditorState( + conversationId = conversationId, + persistedDraft = persistedDraft, + isLoaded = true, + isSending = isSending, + pendingAttachments = pendingAttachments, + pendingSentDraft = pendingSentDraft, + ) + } + + protected companion object { + const val CONVERSATION_ID = "conversation-1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/ConversationDraftEditsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/ConversationDraftEditsTest.kt new file mode 100644 index 000000000..4ea120583 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/ConversationDraftEditsTest.kt @@ -0,0 +1,129 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftEdits +import kotlinx.collections.immutable.persistentListOf +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 ConversationDraftEditsTest : BaseDraftEditorStateTest() { + + @Test + fun hasChanges_whenAllFieldsNull_returnsFalse() { + assertFalse(ConversationDraftEdits().hasChanges) + } + + @Test + fun hasChanges_withMessageTextSet_returnsTrue() { + assertTrue(ConversationDraftEdits(messageText = "text").hasChanges) + } + + @Test + fun hasChanges_withSubjectTextSet_returnsTrue() { + assertTrue(ConversationDraftEdits(subjectText = "subject").hasChanges) + } + + @Test + fun hasChanges_withSelfParticipantIdSet_returnsTrue() { + assertTrue(ConversationDraftEdits(selfParticipantId = "sim-1").hasChanges) + } + + @Test + fun hasChanges_withEmptyButNonNullAttachments_returnsTrue() { + assertTrue(ConversationDraftEdits(attachments = persistentListOf()).hasChanges) + } + + @Test + fun applyTo_withNullFields_returnsBaseValuesUnchanged() { + val base = draft( + messageText = "message", + subjectText = "subject", + selfParticipantId = "sim-1", + attachments = listOf(attachment("content://attachment/1")), + ) + + assertEquals(base, ConversationDraftEdits().applyTo(base)) + } + + @Test + fun applyTo_withSetFields_overridesBaseValues() { + val base = + draft(messageText = "message", subjectText = "subject", selfParticipantId = "sim-1") + val edits = ConversationDraftEdits( + messageText = "new-message", + subjectText = "new-subject", + selfParticipantId = "sim-2", + attachments = persistentListOf(attachment("content://attachment/9")), + ) + + assertEquals( + draft( + messageText = "new-message", + subjectText = "new-subject", + selfParticipantId = "sim-2", + attachments = listOf(attachment("content://attachment/9")), + ), + edits.applyTo(base), + ) + } + + @Test + fun applyTo_preservesBaseFlagsNotCoveredByEdits() { + val base = draft(messageText = "message", isCheckingDraft = true, isSending = true) + + val result = ConversationDraftEdits(messageText = "new-message").applyTo(base) + + assertTrue(result.isCheckingDraft) + assertTrue(result.isSending) + } + + @Test + fun normalizedAgainst_dropsFieldsEqualToBase() { + val base = + draft(messageText = "message", subjectText = "subject", selfParticipantId = "sim-1") + val edits = ConversationDraftEdits( + messageText = "message", + subjectText = "subject", + selfParticipantId = "sim-1", + attachments = base.attachments, + ) + + assertFalse(edits.normalizedAgainst(base).hasChanges) + } + + @Test + fun normalizedAgainst_keepsEveryFieldThatDiffersFromBase() { + val base = draft( + messageText = "message", + subjectText = "subject", + selfParticipantId = "sim-1", + attachments = listOf(attachment("content://attachment/base")), + ) + val edits = ConversationDraftEdits( + messageText = "different-message", + subjectText = "different-subject", + selfParticipantId = "sim-2", + attachments = persistentListOf(attachment("content://attachment/edit")), + ) + + val normalized = edits.normalizedAgainst(base) + + assertEquals("different-message", normalized.messageText) + assertEquals("different-subject", normalized.subjectText) + assertEquals("sim-2", normalized.selfParticipantId) + assertEquals(listOf(attachment("content://attachment/edit")), normalized.attachments) + } + + @Test + fun normalizedAgainst_keepsDifferingFieldWhileDroppingEqualField() { + val base = draft(messageText = "message", subjectText = "subject") + + val normalized = ConversationDraftEdits(messageText = "different", subjectText = "subject") + .normalizedAgainst(base) + + assertEquals("different", normalized.messageText) + assertNull(normalized.subjectText) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateAttachmentsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateAttachmentsTest.kt new file mode 100644 index 000000000..788323ee9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateAttachmentsTest.kt @@ -0,0 +1,153 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +internal class DraftEditorStateAttachmentsTest : BaseDraftEditorStateTest() { + + @Test + fun withAttachmentsAdded_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.withAttachmentsAdded(listOf(attachment("content://attachment/1")))) + } + + @Test + fun withAttachmentsAdded_withEmptyCollection_returnsSameState() { + val state = loadedState() + + assertSame(state, state.withAttachmentsAdded(emptyList())) + } + + @Test + fun withAttachmentsAdded_appendsNewAttachmentsPreservingOrder() { + val existing = attachment("content://attachment/1") + val added = attachment("content://attachment/2") + + val state = loadedState(persistedDraft = draft(attachments = listOf(existing))) + .withAttachmentsAdded(listOf(added)) + + assertEquals(listOf(existing, added), state.effectiveDraft.attachments) + } + + @Test + fun withAttachmentsAdded_skipsAttachmentsWithExistingContentUri() { + val existing = attachment("content://attachment/1", captionText = "original") + val duplicate = attachment("content://attachment/1", captionText = "duplicate") + + val state = loadedState(persistedDraft = draft(attachments = listOf(existing))) + + assertSame(state, state.withAttachmentsAdded(listOf(duplicate))) + } + + @Test + fun withAttachmentsAdded_dedupesWithinBatchByContentUriKeepingFirst() { + val first = attachment("content://attachment/1", captionText = "first") + val sameUri = attachment("content://attachment/1", captionText = "second") + + val state = loadedState() + .withAttachmentsAdded(listOf(first, sameUri)) + + assertEquals(listOf(first), state.effectiveDraft.attachments) + } + + @Test + fun withAttachmentsAdded_withMixedNewAndDuplicateBatch_appendsOnlyNewPreservingOrder() { + val existing = attachment("content://attachment/1") + val duplicateOfExisting = attachment("content://attachment/1", captionText = "ignored") + val added = attachment("content://attachment/2") + + val state = loadedState(persistedDraft = draft(attachments = listOf(existing))) + .withAttachmentsAdded(listOf(duplicateOfExisting, added)) + + assertEquals(listOf(existing, added), state.effectiveDraft.attachments) + } + + @Test + fun withAttachmentRemoved_withNullConversationId_returnsSameState() { + val state = DraftEditorState( + conversationId = null, + persistedDraft = draft(attachments = listOf(attachment("content://attachment/1"))), + ) + + assertSame(state, state.withAttachmentRemoved("content://attachment/1")) + } + + @Test + fun withAttachmentRemoved_removesMatchingAttachment() { + val keep = attachment("content://attachment/1") + val remove = attachment("content://attachment/2") + + val state = loadedState(persistedDraft = draft(attachments = listOf(keep, remove))) + .withAttachmentRemoved("content://attachment/2") + + assertEquals(listOf(keep), state.effectiveDraft.attachments) + } + + @Test + fun withAttachmentRemoved_withUnknownContentUri_returnsSameState() { + val state = loadedState( + persistedDraft = draft(attachments = listOf(attachment("content://attachment/1"))), + ) + + assertSame(state, state.withAttachmentRemoved("content://attachment/unknown")) + } + + @Test + fun withAttachmentCaption_withNullConversationId_returnsSameState() { + val state = DraftEditorState( + conversationId = null, + persistedDraft = draft(attachments = listOf(attachment("content://attachment/1"))), + ) + + assertSame(state, state.withAttachmentCaption("content://attachment/1", "caption")) + } + + @Test + fun withAttachmentCaption_updatesCaptionForMatchingAttachment() { + val state = loadedState( + persistedDraft = draft( + attachments = listOf(attachment("content://attachment/1", captionText = "old")), + ), + ).withAttachmentCaption("content://attachment/1", "new") + + assertEquals("new", state.effectiveDraft.attachments.single().captionText) + } + + @Test + fun withAttachmentCaption_withUnknownContentUri_returnsSameState() { + val state = loadedState( + persistedDraft = draft(attachments = listOf(attachment("content://attachment/1"))), + ) + + assertSame(state, state.withAttachmentCaption("content://attachment/unknown", "caption")) + } + + @Test + fun withAttachmentCaption_withUnchangedCaption_returnsSameState() { + val state = loadedState( + persistedDraft = draft( + attachments = listOf(attachment("content://attachment/1", captionText = "same")), + ), + ) + + assertSame(state, state.withAttachmentCaption("content://attachment/1", "same")) + } + + @Test + fun withAttachmentCaption_onMultiElementList_updatesOnlyTargetPreservingOrder() { + val first = attachment("content://attachment/1") + val target = attachment("content://attachment/2", captionText = "old") + val last = attachment("content://attachment/3") + + val state = loadedState(persistedDraft = draft(attachments = listOf(first, target, last))) + .withAttachmentCaption("content://attachment/2", "new") + + assertEquals( + listOf(first, attachment("content://attachment/2", captionText = "new"), last), + state.effectiveDraft.attachments, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateEffectiveDraftTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateEffectiveDraftTest.kt new file mode 100644 index 000000000..822fb8091 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateEffectiveDraftTest.kt @@ -0,0 +1,82 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class DraftEditorStateEffectiveDraftTest : BaseDraftEditorStateTest() { + + @Test + fun effectiveDraft_withoutLocalEdits_returnsPersistedDraft() { + val persisted = draft(messageText = "persisted") + + val state = loadedState(persistedDraft = persisted) + + assertEquals(persisted, state.effectiveDraft) + } + + @Test + fun effectiveDraft_withLocalEdits_appliesEditsOverPersistedDraft() { + val state = loadedState( + persistedDraft = draft(messageText = "persisted", subjectText = "subject"), + ).withMessageText("edited") + + assertEquals(draft(messageText = "edited", subjectText = "subject"), state.effectiveDraft) + } + + @Test + fun visibleState_withNullConversationId_returnsEmptyState() { + val state = DraftEditorState( + conversationId = null, + persistedDraft = draft(messageText = "ignored"), + ) + + assertEquals(ConversationDraftState(), state.visibleState) + } + + @Test + fun visibleState_whenNotLoaded_marksDraftAsCheckingDraft() { + val state = DraftEditorState( + conversationId = CONVERSATION_ID, + persistedDraft = draft(messageText = "hi"), + isLoaded = false, + ) + + assertTrue(state.visibleState.draft.isCheckingDraft) + } + + @Test + fun visibleState_whenLoaded_marksDraftAsNotCheckingDraft() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + + assertFalse(state.visibleState.draft.isCheckingDraft) + } + + @Test + fun visibleState_whenSending_marksDraftAsSending() { + val state = loadedState(persistedDraft = draft(messageText = "hi"), isSending = true) + + assertTrue(state.visibleState.draft.isSending) + } + + @Test + fun visibleState_exposesEffectiveDraftAndPendingAttachments() { + val pending = pendingAttachment("pending-1") + + val state = loadedState( + persistedDraft = draft(messageText = "hi"), + pendingAttachments = listOf(pending), + ).withMessageText("edited") + + assertEquals( + ConversationDraftState( + draft = draft(messageText = "edited"), + pendingAttachments = listOf(pending), + ), + state.visibleState, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePendingAttachmentsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePendingAttachmentsTest.kt new file mode 100644 index 000000000..8184a32b4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePendingAttachmentsTest.kt @@ -0,0 +1,56 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class DraftEditorStatePendingAttachmentsTest : BaseDraftEditorStateTest() { + + @Test + fun withPendingAttachmentAdded_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.withPendingAttachmentAdded(pendingAttachment("pending-1"))) + } + + @Test + fun withPendingAttachmentAdded_appendsPendingAttachment() { + val existing = pendingAttachment("pending-1") + val added = pendingAttachment("pending-2") + + val state = loadedState(pendingAttachments = listOf(existing)) + .withPendingAttachmentAdded(added) + + assertEquals(listOf(existing, added), state.pendingAttachments) + } + + @Test + fun withPendingAttachmentRemoved_removesMatchingPendingAttachment() { + val keep = pendingAttachment("pending-1") + val remove = pendingAttachment("pending-2") + + val state = loadedState(pendingAttachments = listOf(keep, remove)) + .withPendingAttachmentRemoved("pending-2") + + assertEquals(listOf(keep), state.pendingAttachments) + } + + @Test + fun withPendingAttachmentRemoved_withUnknownId_returnsSameState() { + val state = loadedState(pendingAttachments = listOf(pendingAttachment("pending-1"))) + + assertSame(state, state.withPendingAttachmentRemoved("unknown")) + } + + @Test + fun withPendingAttachmentRemoved_withNullConversationId_stillRemoves() { + val state = DraftEditorState( + conversationId = null, + pendingAttachments = listOf(pendingAttachment("pending-1")), + ) + + assertTrue(state.withPendingAttachmentRemoved("pending-1").pendingAttachments.isEmpty()) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePersistedDraftTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePersistedDraftTest.kt new file mode 100644 index 000000000..fd5466877 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStatePersistedDraftTest.kt @@ -0,0 +1,83 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class DraftEditorStatePersistedDraftTest : BaseDraftEditorStateTest() { + + @Test + fun withPersistedDraft_withoutPendingSentDraft_setsPersistedDraftAndMarksLoaded() { + val state = DraftEditorState(conversationId = CONVERSATION_ID, isLoaded = false) + + val result = state.withPersistedDraft(draft(messageText = "loaded")) + + assertEquals(draft(messageText = "loaded"), result.persistedDraft) + assertEquals(draft(messageText = "loaded"), result.effectiveDraft) + assertTrue(result.isLoaded) + } + + @Test + fun withPersistedDraft_normalizesLocalEditsThatMatchNewPersistedDraft() { + val state = DraftEditorState(conversationId = CONVERSATION_ID, isLoaded = true) + .withMessageText("loaded") + + val result = state.withPersistedDraft(draft(messageText = "loaded")) + + assertEquals(draft(messageText = "loaded"), result.effectiveDraft) + assertNull(result.toSaveRequestOrNull()) + } + + @Test + fun withPersistedDraft_preservesLocalEditsThatDifferFromNewPersistedDraft() { + val state = DraftEditorState(conversationId = CONVERSATION_ID, isLoaded = true) + .withMessageText("local") + + val result = state.withPersistedDraft(draft(messageText = "remote")) + + assertEquals(draft(messageText = "local"), result.effectiveDraft) + assertEquals(draft(messageText = "remote"), result.persistedDraft) + } + + @Test + fun withPersistedDraft_whileAwaitingClear_whenPersistedMatchesSent_keepsPending() { + val sentDraft = draft(messageText = "sent") + val state = loadedState(persistedDraft = draft(messageText = "sent")) + .clearDraftAfterSend(sentDraft) + + val result = state.withPersistedDraft(sentDraft) + + assertEquals(sentDraft, result.pendingSentDraft) + assertEquals(sentDraft, result.persistedDraft) + assertEquals(draft(), result.effectiveDraft) + } + + @Test + fun withPersistedDraft_whileAwaitingClear_whenRemoteChangedAndVisibleCleared_clearsPending() { + val sentDraft = draft(messageText = "sent") + val state = loadedState(persistedDraft = draft(messageText = "sent")) + .clearDraftAfterSend(sentDraft) + + val result = state.withPersistedDraft(draft(messageText = "remote-update")) + + assertNull(result.pendingSentDraft) + assertEquals(draft(messageText = "remote-update"), result.persistedDraft) + assertEquals(draft(messageText = "remote-update"), result.effectiveDraft) + } + + @Test + fun withPersistedDraft_whileAwaitingClear_whenUserTyped_rebasesVisibleAndClearsPending() { + val sentDraft = draft(messageText = "sent") + val state = loadedState(persistedDraft = draft(messageText = "sent")) + .clearDraftAfterSend(sentDraft) + .withMessageText("typed-after-send") + + val result = state.withPersistedDraft(draft(messageText = "remote-update")) + + assertNull(result.pendingSentDraft) + assertEquals(draft(messageText = "remote-update"), result.persistedDraft) + assertEquals(draft(messageText = "typed-after-send"), result.effectiveDraft) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSaveTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSaveTest.kt new file mode 100644 index 000000000..ea7f254f3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSaveTest.kt @@ -0,0 +1,215 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import com.android.messaging.ui.conversation.composer.delegate.DraftSaveRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class DraftEditorStateSaveTest : BaseDraftEditorStateTest() { + + @Test + fun toSaveRequestOrNull_withNullConversationId_returnsNull() { + val state = DraftEditorState(conversationId = null, isLoaded = true) + + assertNull(state.toSaveRequestOrNull()) + } + + @Test + fun toSaveRequestOrNull_whenNotLoaded_returnsNull() { + val state = DraftEditorState(conversationId = CONVERSATION_ID, isLoaded = false) + .withMessageText("hi") + + assertNull(state.toSaveRequestOrNull()) + } + + @Test + fun toSaveRequestOrNull_whenSending_returnsNull() { + val state = loadedState(isSending = true).withMessageText("hi") + + assertNull(state.toSaveRequestOrNull()) + } + + @Test + fun toSaveRequestOrNull_withNoRecordedChanges_returnsNull() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + + assertNull(state.toSaveRequestOrNull()) + } + + @Test + fun toSaveRequestOrNull_whenLoadedWithChanges_returnsRequestWithEffectiveDraft() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + assertEquals( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello") + ), + state.toSaveRequestOrNull(), + ) + } + + @Test + fun matchesSaveRequest_withDifferentConversationId_returnsFalse() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + assertFalse( + state.matchesSaveRequest( + DraftSaveRequest(conversationId = "other", draft = draft(messageText = "hello")), + ), + ) + } + + @Test + fun matchesSaveRequest_whenNotLoaded_returnsFalse() { + val state = DraftEditorState(conversationId = CONVERSATION_ID, isLoaded = false) + .withMessageText("hello") + + assertFalse( + state.matchesSaveRequest( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello") + ), + ), + ) + } + + @Test + fun matchesSaveRequest_whenSending_returnsFalse() { + val state = loadedState(isSending = true).withMessageText("hello") + + assertFalse( + state.matchesSaveRequest( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello") + ), + ), + ) + } + + @Test + fun matchesSaveRequest_withNoRecordedChanges_returnsFalse() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + + assertFalse( + state.matchesSaveRequest( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hi") + ), + ), + ) + } + + @Test + fun matchesSaveRequest_whenEffectiveDraftMatchesRequest_returnsTrue() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + assertTrue( + state.matchesSaveRequest( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello") + ), + ), + ) + } + + @Test + fun matchesSaveRequest_whenEffectiveDraftDiffersFromRequest_returnsFalse() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + assertFalse( + state.matchesSaveRequest( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "different") + ), + ), + ) + } + + @Test + fun withPersistedSaveResult_withDifferentConversationId_returnsSameState() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + assertSame( + state, + state.withPersistedSaveResult( + DraftSaveRequest(conversationId = "other", draft = draft(messageText = "hello")), + ), + ) + } + + @Test + fun withPersistedSaveResult_whenEffectiveDraftMatchesSavedDraft_persistsAndClearsChanges() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + val result = state.withPersistedSaveResult( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello") + ), + ) + + assertEquals(draft(messageText = "hello"), result.persistedDraft) + assertEquals(draft(messageText = "hello"), result.effectiveDraft) + assertNull(result.toSaveRequestOrNull()) + } + + @Test + fun withPersistedSaveResult_whenEffectiveDraftDiverged_rebasesEditsOnSavedDraft() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("v1") + .withMessageText("v2") + + val result = state.withPersistedSaveResult( + DraftSaveRequest(conversationId = CONVERSATION_ID, draft = draft(messageText = "v1")), + ) + + assertEquals(draft(messageText = "v1"), result.persistedDraft) + assertEquals(draft(messageText = "v2"), result.effectiveDraft) + } + + @Test + fun withPersistedSaveResult_whenDivergedAcrossAllFields_rebasesEachFieldOntoSavedDraft() { + val localDraft = draft( + messageText = "local-message", + subjectText = "local-subject", + selfParticipantId = "sim-local", + attachments = listOf(attachment("content://attachment/local")), + ) + val state = loadedState(persistedDraft = draft()) + .withMessageText(localDraft.messageText) + .withSubjectText(localDraft.subjectText) + .withSelfParticipantId(localDraft.selfParticipantId) + .withAttachmentsAdded(localDraft.attachments) + + val saved = DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft( + messageText = "saved-message", + subjectText = "saved-subject", + selfParticipantId = "sim-saved", + attachments = listOf(attachment("content://attachment/saved")), + ), + ) + + val result = state.withPersistedSaveResult(saved) + + assertEquals(localDraft, result.effectiveDraft) + assertEquals(saved.draft, result.persistedDraft) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSeededDraftTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSeededDraftTest.kt new file mode 100644 index 000000000..be79d1ba6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSeededDraftTest.kt @@ -0,0 +1,73 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class DraftEditorStateSeededDraftTest : BaseDraftEditorStateTest() { + + @Test + fun withSeededDraft_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.withSeededDraft(draft(messageText = "seed"))) + } + + @Test + fun withSeededDraft_replacesLocalEditsWithSeededContent() { + val state = loadedState(persistedDraft = draft(messageText = "persisted")) + .withSeededDraft(draft(messageText = "seed", subjectText = "subject")) + + assertEquals(draft(messageText = "seed", subjectText = "subject"), state.effectiveDraft) + } + + @Test + fun withSeededDraft_withAttachments_includesThemInEffectiveDraft() { + val seededAttachment = attachment("content://attachment/1") + + val state = loadedState() + .withSeededDraft(draft(attachments = listOf(seededAttachment))) + + assertEquals(listOf(seededAttachment), state.effectiveDraft.attachments) + } + + @Test + fun withSeededDraft_withBlankSelfParticipantId_fallsBackToPersistedSelfParticipantId() { + val state = loadedState(persistedDraft = draft(selfParticipantId = "sim-persisted")) + .withSeededDraft(draft(messageText = "seed", selfParticipantId = " ")) + + assertEquals("sim-persisted", state.effectiveDraft.selfParticipantId) + } + + @Test + fun withSeededDraft_withNonBlankSelfParticipantId_usesSeededSelfParticipantId() { + val state = loadedState(persistedDraft = draft(selfParticipantId = "sim-persisted")) + .withSeededDraft(draft(messageText = "seed", selfParticipantId = "sim-seed")) + + assertEquals("sim-seed", state.effectiveDraft.selfParticipantId) + } + + @Test + fun withSeededDraft_whenSeededContentEqualsPersisted_recordsNoChange() { + val persisted = draft(messageText = "persisted", subjectText = "subject") + + val state = loadedState(persistedDraft = persisted) + .withSeededDraft(persisted) + + assertEquals(persisted, state.effectiveDraft) + assertNull(state.toSaveRequestOrNull()) + } + + @Test + fun withSeededDraft_replacesPriorLocalEditsDroppingAttachmentsNotInSeed() { + val state = loadedState() + .withAttachmentsAdded(listOf(attachment("content://attachment/old"))) + .withSeededDraft(draft(messageText = "seed")) + + assertEquals("seed", state.effectiveDraft.messageText) + assertTrue(state.effectiveDraft.attachments.isEmpty()) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSendLifecycleTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSendLifecycleTest.kt new file mode 100644 index 000000000..b3a50469a --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateSendLifecycleTest.kt @@ -0,0 +1,158 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class DraftEditorStateSendLifecycleTest : BaseDraftEditorStateTest() { + + @Test + fun canSendDraft_withNullConversationId_returnsFalse() { + val state = DraftEditorState( + conversationId = null, + persistedDraft = draft(messageText = "hi"), + isLoaded = true, + ) + + assertFalse(state.canSendDraft()) + } + + @Test + fun canSendDraft_whenNotLoaded_returnsFalse() { + val state = DraftEditorState( + conversationId = CONVERSATION_ID, + persistedDraft = draft(messageText = "hi"), + isLoaded = false, + ) + + assertFalse(state.canSendDraft()) + } + + @Test + fun canSendDraft_whenSending_returnsFalse() { + val state = loadedState(persistedDraft = draft(messageText = "hi"), isSending = true) + + assertFalse(state.canSendDraft()) + } + + @Test + fun canSendDraft_withPendingAttachments_returnsFalse() { + val state = loadedState( + persistedDraft = draft(messageText = "hi"), + pendingAttachments = listOf(pendingAttachment("pending-1")), + ) + + assertFalse(state.canSendDraft()) + } + + @Test + fun canSendDraft_withoutContent_returnsFalse() { + val state = loadedState(persistedDraft = draft()) + + assertFalse(state.canSendDraft()) + } + + @Test + fun canSendDraft_whenLoadedWithContentAndNoBlockers_returnsTrue() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + + assertTrue(state.canSendDraft()) + } + + @Test + fun canSendDraft_withSubjectOnlyContent_returnsTrue() { + val state = loadedState(persistedDraft = draft(subjectText = "subject")) + + assertTrue(state.canSendDraft()) + } + + @Test + fun canSendDraft_withAttachmentOnlyContent_returnsTrue() { + val state = loadedState( + persistedDraft = draft(attachments = listOf(attachment("content://attachment/1"))), + ) + + assertTrue(state.canSendDraft()) + } + + @Test + fun markSending_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.markSending()) + } + + @Test + fun markSending_whenAlreadySending_returnsSameState() { + val state = loadedState(isSending = true) + + assertSame(state, state.markSending()) + } + + @Test + fun markSending_whenIdle_setsSending() { + val state = loadedState(isSending = false) + + assertTrue(state.markSending().isSending) + } + + @Test + fun markIdle_whenNotSending_returnsSameState() { + val state = loadedState(isSending = false) + + assertSame(state, state.markIdle()) + } + + @Test + fun markIdle_whenSending_clearsSending() { + val state = loadedState(isSending = true) + + assertFalse(state.markIdle().isSending) + } + + @Test + fun markIdle_withNullConversationId_whenSending_stillClearsSending() { + val state = DraftEditorState(conversationId = null, isSending = true) + + assertFalse(state.markIdle().isSending) + } + + @Test + fun clearDraftAfterSend_whenVisibleDraftMatchesSentDraft_clearsContentKeepingSelfParticipant() { + val sentDraft = draft(messageText = "sent", selfParticipantId = "sim-1") + val state = + loadedState(persistedDraft = draft(messageText = "sent", selfParticipantId = "sim-1")) + + val result = state.clearDraftAfterSend(sentDraft) + + assertEquals(draft(selfParticipantId = "sim-1"), result.effectiveDraft) + assertEquals(sentDraft, result.pendingSentDraft) + } + + @Test + fun clearDraftAfterSend_whenVisibleDiverged_keepsContentAndUpdatesSelf() { + val sentDraft = draft(messageText = "sent", selfParticipantId = "sim-2") + val state = loadedState(persistedDraft = draft(messageText = "sent")) + .withMessageText("sent with edit") + + val result = state.clearDraftAfterSend(sentDraft) + + assertEquals( + draft(messageText = "sent with edit", selfParticipantId = "sim-2"), + result.effectiveDraft, + ) + assertEquals(sentDraft, result.pendingSentDraft) + } + + @Test + fun clearDraftAfterSend_resetsSendingState() { + val state = loadedState(persistedDraft = draft(messageText = "sent"), isSending = true) + + val result = state.clearDraftAfterSend(draft(messageText = "sent")) + + assertFalse(result.isSending) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateTextEditsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateTextEditsTest.kt new file mode 100644 index 000000000..33f1c1dd3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/drafteditorstate/DraftEditorStateTextEditsTest.kt @@ -0,0 +1,101 @@ +package com.android.messaging.ui.conversation.composer.delegate.drafteditorstate + +import com.android.messaging.ui.conversation.composer.delegate.DraftEditorState +import com.android.messaging.ui.conversation.composer.delegate.DraftSaveRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +internal class DraftEditorStateTextEditsTest : BaseDraftEditorStateTest() { + + @Test + fun withMessageText_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.withMessageText("hi")) + } + + @Test + fun withMessageText_whenTextMatchesEffectiveDraft_returnsSameState() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + + assertSame(state, state.withMessageText("hi")) + } + + @Test + fun withMessageText_withNewText_updatesEffectiveDraftAndRecordsChange() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + + assertEquals("hello", state.effectiveDraft.messageText) + assertEquals( + DraftSaveRequest( + conversationId = CONVERSATION_ID, + draft = draft(messageText = "hello") + ), + state.toSaveRequestOrNull(), + ) + } + + @Test + fun withMessageText_revertingToPersistedValue_clearsRecordedChange() { + val state = loadedState(persistedDraft = draft(messageText = "hi")) + .withMessageText("hello") + .withMessageText("hi") + + assertEquals("hi", state.effectiveDraft.messageText) + assertNull(state.toSaveRequestOrNull()) + } + + @Test + fun withSubjectText_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.withSubjectText("subject")) + } + + @Test + fun withSubjectText_whenTextMatchesEffectiveDraft_returnsSameState() { + val state = loadedState(persistedDraft = draft(subjectText = "subject")) + + assertSame(state, state.withSubjectText("subject")) + } + + @Test + fun withSubjectText_withNewText_updatesEffectiveDraft() { + val state = loadedState(persistedDraft = draft(subjectText = "old")) + .withSubjectText("new") + + assertEquals("new", state.effectiveDraft.subjectText) + } + + @Test + fun withSelfParticipantId_withNullConversationId_returnsSameState() { + val state = DraftEditorState(conversationId = null) + + assertSame(state, state.withSelfParticipantId("sim-1")) + } + + @Test + fun withSelfParticipantId_withBlankId_returnsSameState() { + val state = loadedState(persistedDraft = draft(selfParticipantId = "sim-1")) + + assertSame(state, state.withSelfParticipantId(" ")) + } + + @Test + fun withSelfParticipantId_whenIdMatchesEffectiveDraft_returnsSameState() { + val state = loadedState(persistedDraft = draft(selfParticipantId = "sim-1")) + + assertSame(state, state.withSelfParticipantId("sim-1")) + } + + @Test + fun withSelfParticipantId_withNewId_updatesEffectiveDraft() { + val state = loadedState(persistedDraft = draft(selfParticipantId = "sim-1")) + .withSelfParticipantId("sim-2") + + assertEquals("sim-2", state.effectiveDraft.selfParticipantId) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapperImplTest.kt new file mode 100644 index 000000000..f1cde5d2b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapperImplTest.kt @@ -0,0 +1,152 @@ +package com.android.messaging.ui.conversation.composer.mapper + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationComposerAttachmentUiModelMapperImplTest { + + @Test + fun map_returnsTypedUiModelsForResolvedAndPendingAttachments() { + val vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + subtitleText = "555-000-8901", + ) + val vCardAttachmentUiModelMapper = mockk() + every { + vCardAttachmentUiModelMapper.map( + metadata = ConversationVCardAttachmentMetadata.Loading, + ) + } returns vCardUiModel + val mapper = ConversationComposerAttachmentUiModelMapperImpl( + conversationVCardAttachmentUiModelMapper = vCardAttachmentUiModelMapper, + ) + + val uiModels = mapper.map( + attachments = listOf( + ConversationDraftAttachment( + contentType = "audio/mpeg", + contentUri = "content://attachments/audio/1", + ), + ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://attachments/image/1", + width = 640, + height = 480, + ), + ConversationDraftAttachment( + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + ), + ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = "content://attachments/video/1", + width = 1920, + height = 1080, + ), + ConversationDraftAttachment( + contentType = "application/pdf", + contentUri = "content://attachments/file/1", + ), + ), + pendingAttachments = listOf( + ConversationDraftPendingAttachment( + pendingAttachmentId = "pending-1", + contentType = "image/jpeg", + contentUri = "content://pending/1", + displayName = "pending.jpg", + ), + ), + ) + + assertEquals( + listOf( + ComposerAttachmentUiModel.Resolved.Audio( + key = "content://attachments/audio/1", + contentType = "audio/mpeg", + contentUri = "content://attachments/audio/1", + durationMillis = 0L, + ), + ComposerAttachmentUiModel.Resolved.VisualMedia.Image( + key = "content://attachments/image/1", + contentType = "image/jpeg", + contentUri = "content://attachments/image/1", + captionText = "", + width = 640, + height = 480, + ), + ComposerAttachmentUiModel.Resolved.VCard( + key = "content://attachments/vcard/1", + contentType = "text/x-vCard", + contentUri = "content://attachments/vcard/1", + vCardUiModel = vCardUiModel, + ), + ComposerAttachmentUiModel.Resolved.VisualMedia.Video( + key = "content://attachments/video/1", + contentType = "video/mp4", + contentUri = "content://attachments/video/1", + captionText = "", + width = 1920, + height = 1080, + ), + ComposerAttachmentUiModel.Resolved.File( + key = "content://attachments/file/1", + contentType = "application/pdf", + contentUri = "content://attachments/file/1", + ), + ComposerAttachmentUiModel.Pending.Generic( + key = "pending-1", + contentType = "image/jpeg", + contentUri = "content://pending/1", + displayName = "pending.jpg", + ), + ), + uiModels, + ) + verify(exactly = 1) { + vCardAttachmentUiModelMapper.map( + metadata = ConversationVCardAttachmentMetadata.Loading, + ) + } + } + + @Test + fun map_audioFinalizingKind_mapsToAudioFinalizingPendingUiModel() { + val mapper = ConversationComposerAttachmentUiModelMapperImpl( + conversationVCardAttachmentUiModelMapper = mockk(), + ) + + val uiModels = mapper.map( + attachments = emptyList(), + pendingAttachments = listOf( + ConversationDraftPendingAttachment( + pendingAttachmentId = "pending-audio-finalizing-1", + contentType = "audio/3gpp", + contentUri = "pending://audio/pending-audio-finalizing-1", + kind = ConversationDraftPendingAttachmentKind.AudioFinalizing, + ), + ), + ) + + assertEquals( + ComposerAttachmentUiModel.Pending.AudioFinalizing( + key = "pending-audio-finalizing-1", + contentType = "audio/3gpp", + contentUri = "pending://audio/pending-audio-finalizing-1", + displayName = "", + ), + uiModels.single(), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt index a6d352a40..e2294c79a 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt @@ -2,21 +2,188 @@ package com.android.messaging.ui.conversation.composer.mapper import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel import com.android.messaging.data.subscription.model.Subscription import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsConfig import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic 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 +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) internal class ConversationComposerUiStateMapperImplTest { private val mapper = ConversationComposerUiStateMapperImpl() + @Before + fun setUp() { + val mmsConfig = mockk() + every { mmsConfig.multipartSmsEnabled } returns true + every { mmsConfig.sendMultipartSmsAsSeparateMessages } returns false + every { mmsConfig.smsToMmsTextLengthThreshold } returns -1 + every { mmsConfig.smsToMmsTextThreshold } returns -1 + mockkStatic(MmsConfig::class) + every { MmsConfig.get(any()) } returns mmsConfig + } + + @After + fun tearDown() { + unmockkStatic(MmsConfig::class) + } + + @Test + fun map_enablesSendOnlyWhenContentIsAvailableAndDraftIsIdle() { + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertTrue(uiState.isSendEnabled) + } + + @Test + fun map_disablesSendWhenDraftIsEmpty() { + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft(), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertFalse(uiState.isSendEnabled) + } + + @Test + fun map_disablesSendAndAttachmentWhenDraftIsBusy() { + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + isCheckingDraft = true, + isSending = true, + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertFalse(uiState.isAttachmentActionEnabled) + assertFalse(uiState.isSendEnabled) + } + + @Test + fun map_keepsMessageFieldTiedToAvailabilityOnly() { + val unavailableAvailability = ConversationComposerAvailability.Unavailable( + reason = ConversationComposerDisabledReason.CONVERSATION_UNAVAILABLE, + ) + + val unavailableUiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + isCheckingDraft = true, + isSending = true, + ), + ), + attachments = persistentListOf(), + composerAvailability = unavailableAvailability, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + val availableUiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + isCheckingDraft = true, + isSending = true, + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertFalse(unavailableUiState.isMessageFieldEnabled) + assertTrue(availableUiState.isMessageFieldEnabled) + } + + @Test + fun map_preservesSendProtocolForWorkingDraft() { + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + ), + sendProtocol = ConversationDraftSendProtocol.MMS, + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertEquals(ConversationDraftSendProtocol.MMS, uiState.sendProtocol) + } + + @Test + fun map_resetsSendProtocolToSmsForEmptyDraft() { + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft(), + sendProtocol = ConversationDraftSendProtocol.MMS, + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertEquals(ConversationDraftSendProtocol.SMS, uiState.sendProtocol) + } + @Test fun map_withoutDraftSelfParticipant_usesDefaultSmsSubscription() { val firstSubscription = firstSubscription() @@ -131,24 +298,131 @@ internal class ConversationComposerUiStateMapperImplTest { assertTrue(uiState.simSelector.isLoading) } + @Test + fun map_selectsSubscriptionMatchingDraftSelfParticipantId() { + val matchingSubscription = createSubscription( + selfParticipantId = "sub-b", + subId = SECOND_SUB_ID, + slotId = 2, + ) + val subscriptions = persistentListOf( + createSubscription( + selfParticipantId = "sub-a", + subId = FIRST_SUB_ID, + slotId = 1, + ), + matchingSubscription, + ) + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + selfParticipantId = "sub-b", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = subscriptions, + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = FIRST_SUB_ID, + ) + + assertEquals(matchingSubscription, uiState.simSelector.selectedSubscription) + assertEquals(subscriptions, uiState.simSelector.subscriptions) + assertTrue(uiState.simSelector.isAvailable) + } + + @Test + fun map_leavesSimSelectorUnavailableForSingleOrEmptySubscriptionList() { + val emptyUiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft(), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + val singleUiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft(), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf( + createSubscription( + selfParticipantId = "sub-a", + subId = FIRST_SUB_ID, + slotId = 1, + ), + ), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = FIRST_SUB_ID, + ) + + assertFalse(emptyUiState.simSelector.isAvailable) + assertNull(emptyUiState.simSelector.selectedSubscription) + assertFalse(singleUiState.simSelector.isAvailable) + } + + @Test + fun map_preservesProvidedAttachments() { + val attachments = persistentListOf( + ComposerAttachmentUiModel.Resolved.File( + key = "attachment-1", + contentType = "application/pdf", + contentUri = "content://provided/attachment/1", + ), + ) + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Draft text", + ), + ), + attachments = attachments, + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertEquals(attachments, uiState.attachments) + } + private fun firstSubscription(): Subscription { - return Subscription( + return createSubscription( selfParticipantId = FIRST_SELF_PARTICIPANT_ID, subId = FIRST_SUB_ID, - label = ConversationSubscriptionLabel.Named(name = "SIM 1"), - displayDestination = null, - displaySlotId = 1, - color = 0, + slotId = 1, ) } private fun secondSubscription(): Subscription { - return Subscription( + return createSubscription( selfParticipantId = SECOND_SELF_PARTICIPANT_ID, subId = SECOND_SUB_ID, - label = ConversationSubscriptionLabel.Named(name = "SIM 2"), + slotId = 2, + ) + } + + private fun createSubscription( + selfParticipantId: String, + subId: Int, + slotId: Int, + ): Subscription { + return Subscription( + selfParticipantId = selfParticipantId, + subId = subId, + label = ConversationSubscriptionLabel.Slot(slotId = slotId), displayDestination = null, - displaySlotId = 2, + displaySlotId = slotId, color = 0, ) } From cbc7de646eeded03667941c54c52bc4f742f961f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:36:05 +0300 Subject: [PATCH 07/38] Add conversation entry unit tests --- .../AddParticipantsViewModelTest.kt | 341 +++++++++++ .../entry/ConversationEntryViewModelTest.kt | 255 ++++++++ .../entry/newchat/BaseNewChatViewModelTest.kt | 3 +- ...ChatViewModelConversationResolutionTest.kt | 229 ++++++++ .../NewChatViewModelGroupConfirmationTest.kt | 158 +++++ .../NewChatViewModelGroupCreationTest.kt | 302 ++++++++++ .../NewChatViewModelSimSelectionTest.kt | 1 - .../newchat/NewChatViewModelStateTest.kt | 222 +++++++ .../RecipientPickerViewModelTest.kt | 86 +++ ...pientSelectionHiddenBackspaceTargetTest.kt | 137 +++++ .../RecipientPickerDelegateImplTest.kt | 553 ++++++++++-------- .../BaseConversationResolutionDelegateTest.kt | 56 ++ ...ersationResolutionDelegateLifecycleTest.kt | 187 ++++++ ...nversationResolutionDelegateOutcomeTest.kt | 113 ++++ ...ConversationResolutionDelegateStateTest.kt | 148 +++++ .../BaseSelectedRecipientsDelegateTest.kt | 49 ++ .../SelectedRecipientsDelegateMutationTest.kt | 119 ++++ ...lectedRecipientsDelegateRestorationTest.kt | 57 ++ .../SelectedRecipientsDelegateToggleTest.kt | 139 +++++ 19 files changed, 2904 insertions(+), 251 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModelTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/entry/ConversationEntryViewModelTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelConversationResolutionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupConfirmationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupCreationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModelTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionHiddenBackspaceTargetTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/BaseConversationResolutionDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateLifecycleTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateOutcomeTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/BaseSelectedRecipientsDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateMutationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateRestorationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateToggleTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModelTest.kt new file mode 100644 index 000000000..53fbade04 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModelTest.kt @@ -0,0 +1,341 @@ +package com.android.messaging.ui.conversation.addparticipants + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.recipientpicker.delegate.ConversationResolutionDelegate +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.recipientpicker.delegate.SelectedRecipientsDelegate +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionOutcome +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientToggleOutcome +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +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.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AddParticipantsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun init_bindsDelegates() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPickerDelegate = createRecipientPickerDelegate() + val resolutionDelegate = createResolutionDelegate() + + createViewModel( + recipientPickerDelegate = recipientPickerDelegate, + conversationResolutionDelegate = resolutionDelegate.mock, + ) + advanceUntilIdle() + + verify(exactly = 1) { + recipientPickerDelegate.bind(scope = any()) + } + verify(exactly = 1) { + resolutionDelegate.mock.bind(scope = any()) + } + } + } + + @Test + fun conversationIdChanged_loadsExistingParticipantsAndExcludesThemFromPicker() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPickerDelegate = createRecipientPickerDelegate() + val participant = participant(destination = "+1 555 0100") + val viewModel = createViewModel( + conversationParticipantsRepository = createParticipantsRepository( + participants = persistentListOf(participant), + ), + recipientPickerDelegate = recipientPickerDelegate, + ) + + viewModel.uiState.test { + awaitItem() + + viewModel.onConversationIdChanged(conversationId = CONVERSATION_ID) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertFalse(state.isLoadingConversationParticipants) + assertEquals(listOf(participant), state.existingParticipants) + verify { + recipientPickerDelegate.onExcludedDestinationsChanged( + destinations = setOf("+1 555 0100"), + ) + } + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun recipientClicked_togglesRecipientAndClearsPickerQueryWhenAdded() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selectedRecipientsDelegate = createSelectedRecipientsDelegate( + toggleOutcome = RecipientToggleOutcome.Added, + ) + val recipientPickerDelegate = createRecipientPickerDelegate() + val viewModel = createViewModel( + selectedRecipientsDelegate = selectedRecipientsDelegate, + recipientPickerDelegate = recipientPickerDelegate, + ) + viewModel.onConversationIdChanged(conversationId = CONVERSATION_ID) + advanceUntilIdle() + + viewModel.onRecipientClicked( + recipient = selectedRecipient(destination = " +1 555 0101 "), + ) + + verify(exactly = 1) { + selectedRecipientsDelegate.toggle( + recipient = selectedRecipient(destination = "+1 555 0101"), + canAdd = any(), + ) + } + verify(exactly = 1) { + recipientPickerDelegate.clearQuery() + } + } + } + + @Test + fun confirmClick_resolvesExistingAndSelectedDestinations() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolutionDelegate = createResolutionDelegate() + val selectedRecipientsDelegate = createSelectedRecipientsDelegate( + selectedRecipients = persistentListOf( + selectedRecipient(destination = "+1 555 0101"), + ), + ) + val viewModel = createViewModel( + conversationParticipantsRepository = createParticipantsRepository( + participants = persistentListOf( + participant(destination = "+1 555 0100"), + ), + ), + selectedRecipientsDelegate = selectedRecipientsDelegate, + conversationResolutionDelegate = resolutionDelegate.mock, + ) + viewModel.onConversationIdChanged(conversationId = CONVERSATION_ID) + advanceUntilIdle() + + viewModel.onConfirmClick() + + assertEquals( + listOf(listOf("+1 555 0100", "+1 555 0101")), + resolutionDelegate.resolvedDestinations, + ) + } + } + + @Test + fun confirmClick_overLimitShowsMessageInsteadOfResolving() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolutionDelegate = createResolutionDelegate() + val viewModel = createViewModel( + selectedRecipientsDelegate = createSelectedRecipientsDelegate( + selectedRecipients = persistentListOf( + selectedRecipient(destination = "+1 555 0101"), + ), + ), + conversationResolutionDelegate = resolutionDelegate.mock, + isRecipientLimitExceeded = true, + ) + viewModel.onConversationIdChanged(conversationId = CONVERSATION_ID) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onConfirmClick() + assertEquals( + AddParticipantsEffect.ShowMessage( + messageResId = R.string.too_many_participants, + ), + awaitItem(), + ) + assertTrue(resolutionDelegate.resolvedDestinations.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun resolvedOutcome_clearsSelectionAndNavigates() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selectedRecipientsDelegate = createSelectedRecipientsDelegate() + val resolutionDelegate = createResolutionDelegate() + val viewModel = createViewModel( + selectedRecipientsDelegate = selectedRecipientsDelegate, + conversationResolutionDelegate = resolutionDelegate.mock, + ) + + viewModel.effects.test { + advanceUntilIdle() + resolutionDelegate.outcomesSource.emit( + ConversationResolutionOutcome.Resolved( + conversationId = "conversation-2", + ), + ) + + assertEquals( + AddParticipantsEffect.NavigateToConversation( + conversationId = "conversation-2", + ), + awaitItem(), + ) + verify(exactly = 1) { + selectedRecipientsDelegate.clear() + } + cancelAndIgnoreRemainingEvents() + } + } + } + + private fun createViewModel( + contactDestinationFormatter: ContactDestinationFormatter = createFormatter(), + conversationParticipantsRepository: ConversationParticipantsRepository = + createParticipantsRepository(), + isRecipientLimitExceeded: Boolean = false, + recipientPickerDelegate: RecipientPickerDelegate = createRecipientPickerDelegate(), + selectedRecipientsDelegate: SelectedRecipientsDelegate = createSelectedRecipientsDelegate(), + conversationResolutionDelegate: ConversationResolutionDelegate = + createResolutionDelegate().mock, + ): AddParticipantsViewModel { + return AddParticipantsViewModel( + contactDestinationFormatter = contactDestinationFormatter, + conversationParticipantsRepository = conversationParticipantsRepository, + isConversationRecipientLimitExceeded = { + isRecipientLimitExceeded + }, + recipientPickerDelegate = recipientPickerDelegate, + selectedRecipientsDelegate = selectedRecipientsDelegate, + conversationResolutionDelegate = conversationResolutionDelegate, + savedStateHandle = SavedStateHandle(), + mainDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun createFormatter(): ContactDestinationFormatter { + val formatter = mockk() + every { formatter.canonicalize(value = any()) } answers { + firstArg().trim() + } + return formatter + } + + private fun createParticipantsRepository( + participants: ImmutableList = persistentListOf(), + ): ConversationParticipantsRepository { + val repository = mockk() + every { + repository.getParticipants(conversationId = any()) + } returns flowOf(participants) + return repository + } + + private fun createRecipientPickerDelegate(): RecipientPickerDelegate { + val delegate = mockk(relaxed = true) + every { delegate.state } returns MutableStateFlow(RecipientPickerUiState()) + every { delegate.bind(scope = any()) } just runs + every { delegate.onExcludedDestinationsChanged(destinations = any()) } just runs + every { delegate.clearQuery() } just runs + return delegate + } + + private fun createSelectedRecipientsDelegate( + selectedRecipients: ImmutableList = persistentListOf(), + toggleOutcome: RecipientToggleOutcome = RecipientToggleOutcome.Added, + ): SelectedRecipientsDelegate { + val delegate = mockk(relaxed = true) + every { delegate.state } returns MutableStateFlow(selectedRecipients) + every { + delegate.toggle( + recipient = any(), + canAdd = any(), + ) + } returns toggleOutcome + every { delegate.clear() } just runs + every { delegate.removeWhere(predicate = any()) } just runs + return delegate + } + + private fun createResolutionDelegate(): ConversationResolutionDelegateMock { + val stateFlow = MutableStateFlow( + value = ConversationResolutionState.Idle, + ) + val outcomesSource = MutableSharedFlow() + val resolvedDestinations = mutableListOf>() + val delegate = mockk(relaxed = true) + + every { delegate.state } returns stateFlow + every { delegate.outcomes } returns outcomesSource + every { delegate.bind(scope = any()) } just runs + every { + delegate.resolve( + destinations = any(), + recipientDestination = any(), + ) + } answers { + resolvedDestinations += firstArg>() + } + every { delegate.cancel() } answers { + stateFlow.value = ConversationResolutionState.Idle + } + + return ConversationResolutionDelegateMock( + mock = delegate, + outcomesSource = outcomesSource, + resolvedDestinations = resolvedDestinations, + ) + } + + @Suppress("SameParameterValue") + private fun participant(destination: String): ConversationRecipient { + return ConversationRecipient( + id = destination, + displayName = destination, + destination = destination, + ) + } + + private fun selectedRecipient(destination: String): SelectedRecipient { + return SelectedRecipient( + destination = destination, + label = destination.trim(), + displayDestination = destination.trim(), + photoUri = null, + ) + } + + private data class ConversationResolutionDelegateMock( + val mock: ConversationResolutionDelegate, + val outcomesSource: MutableSharedFlow, + val resolvedDestinations: MutableList>, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/ConversationEntryViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/ConversationEntryViewModelTest.kt new file mode 100644 index 000000000..e3ef25a8d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/ConversationEntryViewModelTest.kt @@ -0,0 +1,255 @@ +package com.android.messaging.ui.conversation.entry + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.entry.model.ConversationEntryUiState +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationEntryViewModelTest { + + @Test + fun launchRequest_setsConversationDraftScrollAndStartupAttachment() { + val draftData = mockk() + val mappedDraft = ConversationDraft(messageText = "Hello") + val mapper = createMapper( + draftData = draftData, + mappedDraft = mappedDraft, + ) + val viewModel = createViewModel(mapper = mapper) + + viewModel.onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 4, + conversationId = CONVERSATION_ID, + draftData = draftData, + startupAttachmentUri = "content://media/1", + startupAttachmentType = "image/png", + messagePosition = 12, + ), + ) + + assertEquals( + ConversationEntryUiState( + launchGeneration = 4, + conversationId = CONVERSATION_ID, + pendingDraft = mappedDraft, + pendingScrollPosition = 12, + pendingStartupAttachment = ConversationEntryStartupAttachment( + contentType = "image/png", + contentUri = "content://media/1", + ), + ), + viewModel.uiState.value, + ) + verify(exactly = 1) { + mapper.map(messageData = draftData) + } + } + + @Test + fun launchRequest_ignoresDuplicateGeneration() { + val draftData = mockk() + val mapper = createMapper( + draftData = draftData, + mappedDraft = ConversationDraft(messageText = "First"), + ) + val viewModel = createViewModel(mapper = mapper) + + viewModel.onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 1, + conversationId = CONVERSATION_ID, + draftData = draftData, + ), + ) + viewModel.onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 1, + conversationId = "conversation-2", + draftData = draftData, + ), + ) + + assertEquals(CONVERSATION_ID, viewModel.uiState.value.conversationId) + verify(exactly = 1) { + mapper.map(messageData = draftData) + } + } + + @Test + fun conversationNavigationRequest_setsConversationAndSelfParticipant() { + val viewModel = createViewModel() + + viewModel.onConversationNavigationRequested( + conversationId = CONVERSATION_ID, + pendingSelfParticipantId = "self-1", + ) + + assertEquals(CONVERSATION_ID, viewModel.uiState.value.conversationId) + assertEquals("self-1", viewModel.uiState.value.pendingSelfParticipantId) + } + + @Test + fun consumeCallbacks_clearOnlyMatchingPendingValues() { + val viewModel = createViewModel() + viewModel.onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 1, + conversationId = CONVERSATION_ID, + draftData = mockk(), + startupAttachmentUri = "content://media/1", + startupAttachmentType = "image/png", + messagePosition = 3, + ), + ) + viewModel.onConversationNavigationRequested( + conversationId = CONVERSATION_ID, + pendingSelfParticipantId = "self-1", + ) + + viewModel.onDraftPayloadConsumed(conversationId = "other") + viewModel.onScrollPositionConsumed(conversationId = "other") + viewModel.onStartupAttachmentConsumed(conversationId = "other") + viewModel.onPendingSelfParticipantIdConsumed(conversationId = "other") + + assertEquals( + ConversationDraft(messageText = "Mapped"), + viewModel.uiState.value.pendingDraft, + ) + assertEquals(3, viewModel.uiState.value.pendingScrollPosition) + assertEquals("self-1", viewModel.uiState.value.pendingSelfParticipantId) + assertEquals( + ConversationEntryStartupAttachment( + contentType = "image/png", + contentUri = "content://media/1", + ), + viewModel.uiState.value.pendingStartupAttachment, + ) + + viewModel.onDraftPayloadConsumed(conversationId = CONVERSATION_ID) + viewModel.onScrollPositionConsumed(conversationId = CONVERSATION_ID) + viewModel.onStartupAttachmentConsumed(conversationId = CONVERSATION_ID) + viewModel.onPendingSelfParticipantIdConsumed(conversationId = CONVERSATION_ID) + + assertNull(viewModel.uiState.value.pendingDraft) + assertNull(viewModel.uiState.value.pendingScrollPosition) + assertNull(viewModel.uiState.value.pendingSelfParticipantId) + assertNull(viewModel.uiState.value.pendingStartupAttachment) + } + + @Test + fun launchRequestState_survivesViewModelRecreationViaSavedStateHandle() { + val draftData = mockk() + val mappedDraft = ConversationDraft(messageText = "Restored") + val mapper = createMapper(draftData = draftData, mappedDraft = mappedDraft) + val savedStateHandle = SavedStateHandle() + + createViewModel(mapper = mapper, savedStateHandle = savedStateHandle).onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 4, + conversationId = CONVERSATION_ID, + draftData = draftData, + startupAttachmentUri = "content://media/1", + startupAttachmentType = "image/png", + messagePosition = 12, + ), + ) + + val recreatedViewModel = createViewModel( + mapper = mapper, + savedStateHandle = savedStateHandle, + ) + + assertEquals( + ConversationEntryUiState( + launchGeneration = 4, + conversationId = CONVERSATION_ID, + pendingDraft = mappedDraft, + pendingScrollPosition = 12, + pendingStartupAttachment = ConversationEntryStartupAttachment( + contentType = "image/png", + contentUri = "content://media/1", + ), + ), + recreatedViewModel.uiState.value, + ) + } + + @Test + fun conversationNavigationState_survivesViewModelRecreationViaSavedStateHandle() { + val savedStateHandle = SavedStateHandle() + + createViewModel(savedStateHandle = savedStateHandle).onConversationNavigationRequested( + conversationId = CONVERSATION_ID, + pendingSelfParticipantId = "self-1", + ) + + val recreatedViewModel = createViewModel(savedStateHandle = savedStateHandle) + + assertEquals(CONVERSATION_ID, recreatedViewModel.uiState.value.conversationId) + assertEquals("self-1", recreatedViewModel.uiState.value.pendingSelfParticipantId) + } + + @Test + fun duplicateLaunchGeneration_isIgnoredAfterViewModelRecreation() { + val savedStateHandle = SavedStateHandle() + + createViewModel(savedStateHandle = savedStateHandle).onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 1, + conversationId = CONVERSATION_ID, + draftData = mockk(), + ), + ) + + val recreatedViewModel = createViewModel(savedStateHandle = savedStateHandle) + recreatedViewModel.onLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = 1, + conversationId = "conversation-2", + draftData = mockk(), + ), + ) + + assertEquals(CONVERSATION_ID, recreatedViewModel.uiState.value.conversationId) + } + + private fun createViewModel( + mapper: ConversationMessageDataDraftMapper = createMapper(), + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): ConversationEntryViewModel { + return ConversationEntryViewModel( + conversationMessageDataDraftMapper = mapper, + savedStateHandle = savedStateHandle, + ) + } + + private fun createMapper( + draftData: MessageData? = null, + mappedDraft: ConversationDraft = ConversationDraft(messageText = "Mapped"), + ): ConversationMessageDataDraftMapper { + val mapper = mockk() + every { + mapper.map(messageData = any()) + } returns mappedDraft + if (draftData != null) { + every { + mapper.map(messageData = draftData) + } returns mappedDraft + } + return mapper + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/BaseNewChatViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/BaseNewChatViewModelTest.kt index 6b7ef6aab..bd40bec08 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/BaseNewChatViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/BaseNewChatViewModelTest.kt @@ -33,7 +33,8 @@ internal abstract class BaseNewChatViewModelTest { val mainDispatcherRule = MainDispatcherRule() protected fun createViewModel( - subscriptionsRepository: SubscriptionsRepository = createSubscriptionsRepositoryMock(), + subscriptionsRepository: SubscriptionsRepository = + createSubscriptionsRepositoryMock(), isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded = createRecipientLimitExceeded(exceeded = false), recipientPickerDelegate: RecipientPickerDelegate = diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelConversationResolutionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelConversationResolutionTest.kt new file mode 100644 index 000000000..769674d2e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelConversationResolutionTest.kt @@ -0,0 +1,229 @@ +package com.android.messaging.ui.conversation.entry.newchat + +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.entry.model.NewChatEffect +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionOutcome +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class NewChatViewModelConversationResolutionTest : BaseNewChatViewModelTest() { + + @Test + fun onContactClicked_whenIdle_resolvesSingleDestination() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + advanceUntilIdle() + + viewModel.onContactClicked(destination = DESTINATION) + + verify(exactly = 1) { + resolution.mock.resolve( + destinations = listOf(DESTINATION), + recipientDestination = DESTINATION, + ) + } + } + } + + @Test + fun onContactClicked_whileResolving_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock( + state = ConversationResolutionState.Resolving( + recipientDestination = DESTINATION, + isIndicatorVisible = false, + ), + ) + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + advanceUntilIdle() + + viewModel.onContactClicked(destination = DESTINATION) + + verify(exactly = 0) { + resolution.mock.resolve(destinations = any(), recipientDestination = any()) + } + } + } + + @Test + fun onContactClicked_whileCreatingGroup_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.onContactClicked(destination = DESTINATION) + + verify(exactly = 0) { + resolution.mock.resolve(destinations = any(), recipientDestination = any()) + } + } + } + + @Test + fun resolvedOutcome_navigatesToConversationWithoutPendingSelfParticipant() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + advanceUntilIdle() + + viewModel.effects.test { + resolution.outcomes.emit( + ConversationResolutionOutcome.Resolved(conversationId = CONVERSATION_ID), + ) + advanceUntilIdle() + assertEquals( + NewChatEffect.NavigateToConversation( + conversationId = CONVERSATION_ID, + selfParticipantId = null, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun resolvedOutcome_clearsSelectedRecipients() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val selected = createSelectedRecipientsDelegateMock() + createViewModel( + conversationResolutionDelegate = resolution.mock, + selectedRecipientsDelegate = selected.mock, + ) + advanceUntilIdle() + + resolution.outcomes.emit( + ConversationResolutionOutcome.Resolved(conversationId = CONVERSATION_ID), + ) + advanceUntilIdle() + + verify(exactly = 1) { selected.mock.clear() } + } + } + + @Test + fun resolvedOutcome_exitsGroupCreationMode() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + + viewModel.uiState.test { + advanceUntilIdle() + viewModel.onCreateGroupRequested() + advanceUntilIdle() + assertTrue(expectMostRecentItem().isCreatingGroup) + + resolution.outcomes.emit( + ConversationResolutionOutcome.Resolved(conversationId = CONVERSATION_ID), + ) + advanceUntilIdle() + assertFalse(expectMostRecentItem().isCreatingGroup) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun resolvedOutcome_afterContactClick_usesSelectedSimSelfParticipantId() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = createSubscriptionsRepositoryMock( + subscriptions = persistentListOf(subscription(selfParticipantId = "self-1")), + ) + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel( + subscriptionsRepository = repository, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onContactClicked(destination = DESTINATION) + advanceUntilIdle() + resolution.outcomes.emit( + ConversationResolutionOutcome.Resolved(conversationId = CONVERSATION_ID), + ) + advanceUntilIdle() + assertEquals( + NewChatEffect.NavigateToConversation( + conversationId = CONVERSATION_ID, + selfParticipantId = "self-1", + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun resolvedOutcome_afterContactClick_withBlankSelfParticipantId_navigatesWithNull() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = createSubscriptionsRepositoryMock( + subscriptions = persistentListOf(subscription(selfParticipantId = "")), + ) + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel( + subscriptionsRepository = repository, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onContactClicked(destination = DESTINATION) + advanceUntilIdle() + resolution.outcomes.emit( + ConversationResolutionOutcome.Resolved(conversationId = CONVERSATION_ID), + ) + advanceUntilIdle() + assertEquals( + NewChatEffect.NavigateToConversation( + conversationId = CONVERSATION_ID, + selfParticipantId = null, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun failedOutcome_emitsConversationCreationFailureMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + advanceUntilIdle() + + viewModel.effects.test { + resolution.outcomes.emit(ConversationResolutionOutcome.Failed) + advanceUntilIdle() + assertEquals( + NewChatEffect.ShowMessage( + messageResId = R.string.conversation_creation_failure, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupConfirmationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupConfirmationTest.kt new file mode 100644 index 000000000..47ad32443 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupConfirmationTest.kt @@ -0,0 +1,158 @@ +package com.android.messaging.ui.conversation.entry.newchat + +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.ui.conversation.entry.model.NewChatEffect +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class NewChatViewModelGroupConfirmationTest : BaseNewChatViewModelTest() { + + @Test + fun onCreateGroupConfirmed_withAcceptedRecipients_resolvesAllDestinationsAsGroup() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selected = createSelectedRecipientsDelegateMock( + recipients = persistentListOf( + selectedRecipient(destination = DESTINATION), + selectedRecipient(destination = DESTINATION_2), + ), + ) + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.onCreateGroupConfirmed() + advanceUntilIdle() + + verify(exactly = 1) { + resolution.mock.resolve( + destinations = listOf(DESTINATION, DESTINATION_2), + recipientDestination = null, + ) + } + } + } + + @Test + fun onCreateGroupConfirmed_withNoRecipients_showsMessageAndDoesNotResolve() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selected = createSelectedRecipientsDelegateMock(recipients = persistentListOf()) + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.effects.test { + viewModel.onCreateGroupConfirmed() + advanceUntilIdle() + assertEquals( + NewChatEffect.ShowMessage(messageResId = R.string.too_many_participants), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 0) { + resolution.mock.resolve(destinations = any(), recipientDestination = any()) + } + } + } + + @Test + fun onCreateGroupConfirmed_whenRecipientLimitExceeded_showsMessageAndDoesNotResolve() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selected = createSelectedRecipientsDelegateMock( + recipients = persistentListOf(selectedRecipient(destination = DESTINATION)), + ) + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel( + isConversationRecipientLimitExceeded = createRecipientLimitExceeded( + exceeded = true + ), + selectedRecipientsDelegate = selected.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.effects.test { + viewModel.onCreateGroupConfirmed() + advanceUntilIdle() + assertEquals( + NewChatEffect.ShowMessage(messageResId = R.string.too_many_participants), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 0) { + resolution.mock.resolve(destinations = any(), recipientDestination = any()) + } + } + } + + @Test + fun onCreateGroupConfirmed_whenNotCreatingGroup_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selected = createSelectedRecipientsDelegateMock( + recipients = persistentListOf(selectedRecipient(destination = DESTINATION)), + ) + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + + viewModel.onCreateGroupConfirmed() + advanceUntilIdle() + + verify(exactly = 0) { + resolution.mock.resolve(destinations = any(), recipientDestination = any()) + } + } + } + + @Test + fun onCreateGroupConfirmed_whileResolving_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selected = createSelectedRecipientsDelegateMock( + recipients = persistentListOf(selectedRecipient(destination = DESTINATION)), + ) + val resolution = createResolutionDelegateMock( + state = ConversationResolutionState.Resolving( + recipientDestination = null, + isIndicatorVisible = false, + ), + ) + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.onCreateGroupConfirmed() + advanceUntilIdle() + + verify(exactly = 0) { + resolution.mock.resolve(destinations = any(), recipientDestination = any()) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupCreationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupCreationTest.kt new file mode 100644 index 000000000..d8a217211 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelGroupCreationTest.kt @@ -0,0 +1,302 @@ +package com.android.messaging.ui.conversation.entry.newchat + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.ui.conversation.entry.model.NewChatEffect +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientToggleOutcome +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class NewChatViewModelGroupCreationTest : BaseNewChatViewModelTest() { + + @Test + fun onCreateGroupRequested_entersGroupModeCancelsResolutionAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel( + conversationResolutionDelegate = resolution.mock, + selectedRecipientsDelegate = selected.mock, + ) + + viewModel.uiState.test { + advanceUntilIdle() + viewModel.onCreateGroupRequested() + advanceUntilIdle() + assertTrue(expectMostRecentItem().isCreatingGroup) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 1) { resolution.mock.cancel() } + verify(exactly = 1) { selected.mock.clear() } + } + } + + @Test + fun onCreateGroupRequested_whenAlreadyCreatingGroup_cancelsButDoesNotClearSelectionAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel( + conversationResolutionDelegate = resolution.mock, + selectedRecipientsDelegate = selected.mock, + ) + advanceUntilIdle() + + viewModel.onCreateGroupRequested() + viewModel.onCreateGroupRequested() + advanceUntilIdle() + + verify(exactly = 1) { selected.mock.clear() } + verify(exactly = 2) { resolution.mock.cancel() } + } + } + + @Test + fun onContactLongClicked_whenNotCreatingGroup_startsGroupWithRecipient() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock() + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + recipientPickerDelegate = recipientPicker.mock, + ) + + viewModel.uiState.test { + advanceUntilIdle() + viewModel.onContactLongClicked(recipient = recipient) + advanceUntilIdle() + assertTrue(expectMostRecentItem().isCreatingGroup) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 1) { selected.mock.replaceWith(recipient = recipient) } + verify(exactly = 1) { recipientPicker.mock.clearQuery() } + } + } + + @Test + fun onContactLongClicked_whenNotCreatingGroup_withBlankDestination_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val blankRecipient = selectedRecipient(destination = " ") + val selected = createSelectedRecipientsDelegateMock() + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + recipientPickerDelegate = recipientPicker.mock, + ) + advanceUntilIdle() + + viewModel.onContactLongClicked(recipient = blankRecipient) + advanceUntilIdle() + + verify(exactly = 0) { selected.mock.replaceWith(recipient = any()) } + verify(exactly = 0) { recipientPicker.mock.clearQuery() } + } + } + + @Test + fun onContactLongClicked_whenStartingGroupExceedsLimit_showsMessageAndDoesNotStartGroup() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel( + isConversationRecipientLimitExceeded = createRecipientLimitExceeded( + exceeded = true + ), + selectedRecipientsDelegate = selected.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onContactLongClicked(recipient = recipient) + advanceUntilIdle() + assertEquals( + NewChatEffect.ShowMessage(messageResId = R.string.too_many_participants), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 0) { selected.mock.replaceWith(recipient = any()) } + } + } + + @Test + fun onContactLongClicked_whileResolving_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock() + val resolution = createResolutionDelegateMock( + state = ConversationResolutionState.Resolving( + recipientDestination = DESTINATION, + isIndicatorVisible = false, + ), + ) + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + + viewModel.onContactLongClicked(recipient = recipient) + advanceUntilIdle() + + verify(exactly = 0) { selected.mock.replaceWith(recipient = any()) } + verify(exactly = 0) { selected.mock.toggle(recipient = any(), canAdd = any()) } + } + } + + @Test + fun onContactLongClicked_whileCreatingGroup_togglesRecipient() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock( + toggleOutcome = RecipientToggleOutcome.Added, + ) + val viewModel = createViewModel(selectedRecipientsDelegate = selected.mock) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.onContactLongClicked(recipient = recipient) + advanceUntilIdle() + + verify(exactly = 1) { selected.mock.toggle(recipient = recipient, canAdd = any()) } + } + } + + @Test + fun onCreateGroupRecipientClicked_whenRecipientAdded_clearsRecipientPickerQuery() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock( + toggleOutcome = RecipientToggleOutcome.Added, + ) + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + recipientPickerDelegate = recipientPicker.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.onCreateGroupRecipientClicked(recipient = recipient) + advanceUntilIdle() + + verify(exactly = 1) { selected.mock.toggle(recipient = recipient, canAdd = any()) } + verify(exactly = 1) { recipientPicker.mock.clearQuery() } + } + } + + @Test + fun onCreateGroupRecipientClicked_whenOverLimit_showsMessageAndKeepsQuery() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock( + toggleOutcome = RecipientToggleOutcome.OverLimit, + ) + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + recipientPickerDelegate = recipientPicker.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.effects.test { + viewModel.onCreateGroupRecipientClicked(recipient = recipient) + advanceUntilIdle() + assertEquals( + NewChatEffect.ShowMessage(messageResId = R.string.too_many_participants), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 0) { recipientPicker.mock.clearQuery() } + } + } + + @Test + fun onCreateGroupRecipientClicked_whenRecipientRemoved_doesNotClearQueryOrShowMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock( + toggleOutcome = RecipientToggleOutcome.Removed, + ) + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel( + selectedRecipientsDelegate = selected.mock, + recipientPickerDelegate = recipientPicker.mock, + ) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.effects.test { + viewModel.onCreateGroupRecipientClicked(recipient = recipient) + advanceUntilIdle() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 0) { recipientPicker.mock.clearQuery() } + } + } + + @Test + fun onCreateGroupRecipientClicked_whenNotCreatingGroup_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipient = selectedRecipient(destination = DESTINATION) + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel(selectedRecipientsDelegate = selected.mock) + advanceUntilIdle() + + viewModel.onCreateGroupRecipientClicked(recipient = recipient) + advanceUntilIdle() + + verify(exactly = 0) { selected.mock.toggle(recipient = any(), canAdd = any()) } + } + } + + @Test + fun onCreateGroupRecipientClicked_withBlankDestination_isIgnored() { + runTest(context = mainDispatcherRule.testDispatcher) { + val blankRecipient = selectedRecipient(destination = " ") + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel(selectedRecipientsDelegate = selected.mock) + advanceUntilIdle() + viewModel.onCreateGroupRequested() + + viewModel.onCreateGroupRecipientClicked(recipient = blankRecipient) + advanceUntilIdle() + + verify(exactly = 0) { selected.mock.toggle(recipient = any(), canAdd = any()) } + } + } + + @Test + fun isCreatingGroupState_survivesViewModelRecreationViaSavedStateHandle() { + runTest(context = mainDispatcherRule.testDispatcher) { + val savedStateHandle = SavedStateHandle() + val firstViewModel = createViewModel(savedStateHandle = savedStateHandle) + advanceUntilIdle() + firstViewModel.onCreateGroupRequested() + advanceUntilIdle() + + val recreatedViewModel = createViewModel(savedStateHandle = savedStateHandle) + + recreatedViewModel.uiState.test { + advanceUntilIdle() + assertTrue(expectMostRecentItem().isCreatingGroup) + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt index 5c71fa715..3f618bb60 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt @@ -170,7 +170,6 @@ internal class NewChatViewModelSimSelectionTest : BaseNewChatViewModelTest() { } } } - @Test fun simSelection_withStalePersistedSelection_fallsBackToDefaultSmsSubscription() { runTest(context = mainDispatcherRule.testDispatcher) { diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelStateTest.kt new file mode 100644 index 000000000..5da6d21ea --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelStateTest.kt @@ -0,0 +1,222 @@ +package com.android.messaging.ui.conversation.entry.newchat + +import app.cash.turbine.test +import com.android.messaging.ui.conversation.entry.model.NewChatEffect +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class NewChatViewModelStateTest : BaseNewChatViewModelTest() { + + @Test + fun init_bindsRecipientPickerAndResolutionDelegatesToTheSameScope() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPicker = createRecipientPickerDelegateMock() + val resolution = createResolutionDelegateMock() + + createViewModel( + recipientPickerDelegate = recipientPicker.mock, + conversationResolutionDelegate = resolution.mock, + ) + advanceUntilIdle() + + assertEquals(1, recipientPicker.bindScopes.size) + assertEquals(1, resolution.bindScopes.size) + assertSame( + recipientPicker.bindScopes.single(), + resolution.bindScopes.single(), + ) + } + } + + @Test + fun uiState_whenResolutionIdle_projectsRecipientPickerAndSelectedRecipients() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPicker = createRecipientPickerDelegateMock( + state = RecipientPickerUiState(query = "Ada", isLoading = true), + ) + val selected = createSelectedRecipientsDelegateMock( + recipients = persistentListOf(selectedRecipient(destination = DESTINATION)), + ) + val viewModel = createViewModel( + recipientPickerDelegate = recipientPicker.mock, + selectedRecipientsDelegate = selected.mock, + ) + + viewModel.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertFalse(state.isCreatingGroup) + assertFalse(state.isResolvingConversation) + assertFalse(state.isResolvingConversationIndicatorVisible) + assertEquals(null, state.resolvingRecipientDestination) + assertEquals( + RecipientPickerUiState(query = "Ada", isLoading = true), + state.recipientPickerUiState, + ) + assertEquals( + persistentListOf(selectedRecipient(destination = DESTINATION)), + state.selectedGroupRecipients, + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun uiState_whenResolving_exposesResolvingFlagsAndIndicatorTransition() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock( + state = ConversationResolutionState.Resolving( + recipientDestination = DESTINATION, + isIndicatorVisible = false, + ), + ) + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + + viewModel.uiState.test { + advanceUntilIdle() + val resolving = expectMostRecentItem() + assertTrue(resolving.isResolvingConversation) + assertFalse(resolving.isResolvingConversationIndicatorVisible) + assertEquals(DESTINATION, resolving.resolvingRecipientDestination) + + resolution.stateFlow.value = ConversationResolutionState.Resolving( + recipientDestination = DESTINATION, + isIndicatorVisible = true, + ) + advanceUntilIdle() + assertTrue(expectMostRecentItem().isResolvingConversationIndicatorVisible) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun uiState_reflectsRecipientPickerAndSelectedRecipientUpdates() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPicker = createRecipientPickerDelegateMock() + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel( + recipientPickerDelegate = recipientPicker.mock, + selectedRecipientsDelegate = selected.mock, + ) + + viewModel.uiState.test { + advanceUntilIdle() + assertEquals( + persistentListOf(), + expectMostRecentItem().selectedGroupRecipients, + ) + + selected.stateFlow.value = + persistentListOf(selectedRecipient(destination = DESTINATION)) + advanceUntilIdle() + assertEquals( + persistentListOf(selectedRecipient(destination = DESTINATION)), + expectMostRecentItem().selectedGroupRecipients, + ) + + recipientPicker.stateFlow.value = RecipientPickerUiState(query = "Bob") + advanceUntilIdle() + assertEquals("Bob", expectMostRecentItem().recipientPickerUiState.query) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onLoadMore_forwardsToRecipientPickerDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel(recipientPickerDelegate = recipientPicker.mock) + advanceUntilIdle() + + viewModel.onLoadMore() + + verify(exactly = 1) { recipientPicker.mock.onLoadMore() } + } + } + + @Test + fun onQueryChanged_forwardsToRecipientPickerDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val recipientPicker = createRecipientPickerDelegateMock() + val viewModel = createViewModel(recipientPickerDelegate = recipientPicker.mock) + advanceUntilIdle() + + viewModel.onQueryChanged(query = "Ada") + + verify(exactly = 1) { recipientPicker.mock.onQueryChanged(query = "Ada") } + } + } + + @Test + fun onNavigateBack_whenNotCreatingGroup_cancelsResolutionAndEmitsNavigateBack() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resolution = createResolutionDelegateMock() + val viewModel = createViewModel(conversationResolutionDelegate = resolution.mock) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onNavigateBack() + advanceUntilIdle() + assertEquals(NewChatEffect.NavigateBack, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 1) { resolution.mock.cancel() } + } + } + + @Test + fun onNavigateBack_whileCreatingGroup_exitsGroupModeAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val selected = createSelectedRecipientsDelegateMock() + val viewModel = createViewModel(selectedRecipientsDelegate = selected.mock) + + viewModel.uiState.test { + advanceUntilIdle() + viewModel.onCreateGroupRequested() + advanceUntilIdle() + assertTrue(expectMostRecentItem().isCreatingGroup) + + viewModel.onNavigateBack() + advanceUntilIdle() + assertFalse(expectMostRecentItem().isCreatingGroup) + cancelAndIgnoreRemainingEvents() + } + verify(exactly = 2) { selected.mock.clear() } + } + } + + @Test + fun onNavigateBack_whileCreatingGroup_doesNotEmitNavigateBack() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onCreateGroupRequested() + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onNavigateBack() + advanceUntilIdle() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModelTest.kt new file mode 100644 index 000000000..cb529ecf8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModelTest.kt @@ -0,0 +1,86 @@ +package com.android.messaging.ui.conversation.recipientpicker + +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertSame +import org.junit.Test + +class RecipientPickerViewModelTest { + + @Test + fun init_bindsDelegateAndExposesState() { + val delegate = createDelegate() + + val viewModel = RecipientPickerViewModel( + recipientPickerDelegate = delegate, + ) + + assertSame(delegate.state, viewModel.uiState) + verify(exactly = 1) { + delegate.bind(scope = any()) + } + } + + @Test + fun onLoadMore_forwardsToDelegate() { + val delegate = createDelegate() + val viewModel = RecipientPickerViewModel( + recipientPickerDelegate = delegate, + ) + + viewModel.onLoadMore() + + verify(exactly = 1) { + delegate.onLoadMore() + } + } + + @Test + fun onExcludedDestinationsChanged_forwardsToDelegate() { + val delegate = createDelegate() + val viewModel = RecipientPickerViewModel( + recipientPickerDelegate = delegate, + ) + + viewModel.onExcludedDestinationsChanged( + destinations = setOf("+15550100"), + ) + + verify(exactly = 1) { + delegate.onExcludedDestinationsChanged( + destinations = setOf("+15550100"), + ) + } + } + + @Test + fun onQueryChanged_forwardsToDelegate() { + val delegate = createDelegate() + val viewModel = RecipientPickerViewModel( + recipientPickerDelegate = delegate, + ) + + viewModel.onQueryChanged(query = "Ada") + + verify(exactly = 1) { + delegate.onQueryChanged(query = "Ada") + } + } + + private fun createDelegate(): RecipientPickerDelegate { + val delegate = mockk(relaxed = true) + every { delegate.state } returns MutableStateFlow(RecipientPickerUiState()) + every { delegate.bind(scope = any()) } just runs + every { delegate.onLoadMore() } just runs + every { delegate.onExcludedDestinationsChanged(destinations = any()) } just runs + every { delegate.onQueryChanged(query = any()) } just runs + + return delegate + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionHiddenBackspaceTargetTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionHiddenBackspaceTargetTest.kt new file mode 100644 index 000000000..ef89c1fb2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionHiddenBackspaceTargetTest.kt @@ -0,0 +1,137 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent +import androidx.compose.ui.text.TextRange +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionQueryFieldUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class RecipientSelectionHiddenBackspaceTargetTest { + + @Test + fun editableText_usesHiddenBackspaceTargetForEmptyQueryWithSelectedRecipients() { + val uiState = queryFieldUiState( + query = "", + selectedRecipients = persistentListOf(selectedRecipient()), + ) + + val fieldText = recipientSelectionQueryFieldEditableText(uiState = uiState) + + assertEquals("", recipientSelectionVisibleQueryText(fieldText = fieldText)) + assertEquals(1, fieldText.length) + } + + @Test + fun editableText_usesQueryWhenQueryIsNotEmpty() { + val uiState = queryFieldUiState( + query = "sam", + selectedRecipients = persistentListOf(selectedRecipient()), + ) + + assertEquals( + "sam", + recipientSelectionQueryFieldEditableText(uiState = uiState), + ) + } + + @Test + fun hardwareBackspace_removesLastRecipientWhenVisibleQueryIsEmptyAndCursorAtStart() { + val uiState = queryFieldUiState( + query = "", + selectedRecipients = persistentListOf(selectedRecipient()), + ) + val fieldText = recipientSelectionQueryFieldEditableText(uiState = uiState) + + val shouldRemove = shouldRemoveLastRecipientFromHardwareBackspace( + keyEvent = backspaceKeyDown(), + text = fieldText, + selection = TextRange(index = 1), + uiState = uiState, + ) + + assertTrue(shouldRemove) + } + + @Test + fun hardwareBackspace_doesNotRemoveRecipientWhenFieldIsDisabled() { + val uiState = queryFieldUiState( + enabled = false, + query = "", + selectedRecipients = persistentListOf(selectedRecipient()), + ) + val fieldText = recipientSelectionQueryFieldEditableText(uiState = uiState) + + val shouldRemove = shouldRemoveLastRecipientFromHardwareBackspace( + keyEvent = backspaceKeyDown(), + text = fieldText, + selection = TextRange(index = 1), + uiState = uiState, + ) + + assertFalse(shouldRemove) + } + + @Test + fun hiddenBackspaceTargetDeleted_removesLastRecipientOnlyForSentinelDeletion() { + val uiState = queryFieldUiState( + query = "", + selectedRecipients = persistentListOf(selectedRecipient()), + ) + val fieldText = recipientSelectionQueryFieldEditableText(uiState = uiState) + + assertTrue( + shouldRemoveLastRecipientAfterHiddenBackspaceTargetDeleted( + previousText = fieldText, + nextText = "", + uiState = uiState, + ), + ) + assertFalse( + shouldRemoveLastRecipientAfterHiddenBackspaceTargetDeleted( + previousText = fieldText, + nextText = "a", + uiState = uiState, + ), + ) + } + + private fun queryFieldUiState( + enabled: Boolean = true, + query: String, + selectedRecipients: ImmutableList = persistentListOf(), + ): RecipientSelectionQueryFieldUiState { + return RecipientSelectionQueryFieldUiState( + query = query, + enabled = enabled, + placeholderText = "To", + selectedRecipients = selectedRecipients, + ) + } + + private fun selectedRecipient(): SelectedRecipient { + return SelectedRecipient( + destination = "+15551234567", + label = "Sam", + displayDestination = "(555) 123-4567", + photoUri = null, + ) + } + + private fun backspaceKeyDown(): ComposeKeyEvent { + return ComposeKeyEvent( + nativeKeyEvent = AndroidKeyEvent( + AndroidKeyEvent.ACTION_DOWN, + AndroidKeyEvent.KEYCODE_DEL, + ), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt index 0ff383cdc..bf4740dbf 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt @@ -8,6 +8,7 @@ import com.android.messaging.data.contact.model.ContactsPage import com.android.messaging.data.contact.repository.ContactsRepository import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.ui.contact.mapper.ContactUiModelMapperImpl import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerListItem import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState @@ -16,24 +17,29 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -internal class RecipientPickerDelegateImplTest { +class RecipientPickerDelegateImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() private val phoneUtilsInstance = mockk(relaxed = true) + private var capturedContactsRepository: ContactsRepository? = null @Before fun setUp() { @@ -59,313 +65,362 @@ internal class RecipientPickerDelegateImplTest { } @Test - fun emptyQueryAndEmptyRepoYieldsEmptyItems() = runTest { - val delegate = createDelegate( - initialQuery = "", - pages = mapOf(searchKey(query = "", offset = 0) to emptyPage()), - ) + fun items_emptyQueryAndEmptyRepository_emitsEmpty() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to emptyPage()), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - assertTrue(finalState.items.isEmpty()) - assertFalse(finalState.canLoadMore) - assertTrue(finalState.hasContactsPermission) - assertFalse(finalState.isLoading) + assertTrue(finalState.items.isEmpty()) + assertFalse(finalState.canLoadMore) + assertTrue(finalState.hasContactsPermission) + assertFalse(finalState.isLoading) + verify { + capturedContactsRepository!!.searchContacts(query = "", offset = 0) + } + } } @Test - fun searchReturnsMultiDestinationContact() = runTest { - val multiDestContact = contact( - id = 11L, - displayName = "Multi Dest", - destinations = listOf( - destination(value = "+15550001", contactId = 11L), - destination(value = "+15550002", contactId = 11L), - destination(value = "multi@example.com", contactId = 11L, isEmail = true), - ), - ) - val delegate = createDelegate( - initialQuery = "multi", - pages = mapOf( - searchKey(query = "multi", offset = 0) to pageOf(multiDestContact), - ), - ) + fun search_multiDestinationContact_returnsContact() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val multiDestContact = contact( + id = 11L, + displayName = "Multi Dest", + destinations = listOf( + destination(value = "+15550001", contactId = 11L), + destination(value = "+15550002", contactId = 11L), + destination(value = "multi@example.com", contactId = 11L, isEmail = true), + ), + ) + val delegate = createDelegate( + initialQuery = "multi", + pages = mapOf( + searchKey(query = "multi", offset = 0) to pageOf(multiDestContact), + ), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val contactItem = finalState.items - .filterIsInstance() - .single() - assertEquals(11L, contactItem.contact.id) - assertEquals(3, contactItem.destinations.size) - assertEquals( - listOf("+15550001", "+15550002", "multi@example.com"), - contactItem.destinations.map { it.value }, - ) + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(11L, contactItem.contact.id) + assertEquals(3, contactItem.destinations.size) + assertEquals( + listOf("+15550001", "+15550002", "multi@example.com"), + contactItem.destinations.map { it.value }, + ) + } } @Test - fun excludedDestinationRemovesMatchingDestinationButKeepsContact() = runTest { - every { - phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("(555) 000-0001") - } returns "+15550001" - val multiDestContact = contact( - id = 12L, - displayName = "Excluded Sample", - destinations = listOf( - destination( - value = "+15550001", - contactId = 12L, - normalizedValue = "+15550001", - ), - destination( - value = "+15550002", - contactId = 12L, - normalizedValue = "+15550002", + fun excludedDestination_matchingDestination_removesOnlyDestination() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("(555) 000-0001") + } returns "+15550001" + val multiDestContact = contact( + id = 12L, + displayName = "Excluded Sample", + destinations = listOf( + destination( + value = "+15550001", + contactId = 12L, + normalizedValue = "+15550001", + ), + destination( + value = "+15550002", + contactId = 12L, + normalizedValue = "+15550002", + ), ), - ), - ) - val delegate = createDelegate( - initialQuery = "", - pages = mapOf(searchKey(query = "", offset = 0) to pageOf(multiDestContact)), - ) - delegate.onExcludedDestinationsChanged(destinations = setOf("(555) 000-0001")) + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to pageOf(multiDestContact)), + ) + delegate.onExcludedDestinationsChanged(destinations = setOf("(555) 000-0001")) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val contactItem = finalState.items - .filterIsInstance() - .single() - assertEquals(1, contactItem.destinations.size) - assertEquals("+15550002", contactItem.destinations.single().value) + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(1, contactItem.destinations.size) + assertEquals("+15550002", contactItem.destinations.single().value) + } } @Test - fun excludedDestinationCanonicalizesIncomingValues() = runTest { - every { - phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("+1 555-000-0001") - } returns "+15550001" - val singleDestContact = contact( - id = 13L, - displayName = "Cross Format", - destinations = listOf( - destination( - value = "(555) 000-0001", - contactId = 13L, - normalizedValue = "+15550001", + fun excludedDestination_incomingValues_areCanonicalized() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("+1 555-000-0001") + } returns "+15550001" + val singleDestContact = contact( + id = 13L, + displayName = "Cross Format", + destinations = listOf( + destination( + value = "(555) 000-0001", + contactId = 13L, + normalizedValue = "+15550001", + ), ), - ), - ) - val delegate = createDelegate( - initialQuery = "", - pages = mapOf(searchKey(query = "", offset = 0) to pageOf(singleDestContact)), - ) - delegate.onExcludedDestinationsChanged(destinations = setOf("+1 555-000-0001")) + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to pageOf(singleDestContact)), + ) + delegate.onExcludedDestinationsChanged(destinations = setOf("+1 555-000-0001")) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val contactItems = finalState.items - .filterIsInstance() - assertTrue(contactItems.isEmpty()) + val contactItems = finalState.items + .filterIsInstance() + assertTrue(contactItems.isEmpty()) + } } @Test - fun excludingAllDestinationsTriggersFallbackToNextPage() = runTest { - val firstPageContact = contact( - id = 21L, - displayName = "All Excluded", - destinations = listOf( - destination(value = "+15550001", contactId = 21L), - destination(value = "+15550002", contactId = 21L), - ), - ) - val secondPageContact = contact( - id = 22L, - displayName = "Has Free Destination", - destinations = listOf(destination(value = "+15550003", contactId = 22L)), - ) - val delegate = createDelegate( - initialQuery = "", - pages = mapOf( - searchKey(query = "", offset = 0) to ContactsPage( - contacts = persistentListOf(firstPageContact), - nextOffset = 1, + fun items_allDestinationsExcluded_loadsNextPage() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val firstPageContact = contact( + id = 21L, + displayName = "All Excluded", + destinations = listOf( + destination(value = "+15550001", contactId = 21L), + destination(value = "+15550002", contactId = 21L), ), - searchKey(query = "", offset = 1) to ContactsPage( - contacts = persistentListOf(secondPageContact), - nextOffset = null, + ) + val secondPageContact = contact( + id = 22L, + displayName = "Has Free Destination", + destinations = listOf(destination(value = "+15550003", contactId = 22L)), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf( + searchKey(query = "", offset = 0) to ContactsPage( + contacts = persistentListOf(firstPageContact), + nextOffset = 1, + ), + searchKey(query = "", offset = 1) to ContactsPage( + contacts = persistentListOf(secondPageContact), + nextOffset = null, + ), ), - ), - ) - delegate.onExcludedDestinationsChanged( - destinations = setOf("+15550001", "+15550002"), - ) + ) + delegate.onExcludedDestinationsChanged( + destinations = setOf("+15550001", "+15550002"), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val contactItem = finalState.items - .filterIsInstance() - .single() - assertEquals(22L, contactItem.contact.id) - assertFalse(finalState.canLoadMore) + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(22L, contactItem.contact.id) + assertFalse(finalState.canLoadMore) + } } @Test - fun loadMoreAppendsNextPage() = runTest { - val firstContact = contact( - id = 31L, - displayName = "Alpha", - destinations = listOf(destination(value = "+11111111", contactId = 31L)), - ) - val secondContact = contact( - id = 32L, - displayName = "Beta", - destinations = listOf(destination(value = "+22222222", contactId = 32L)), - ) - val delegate = createDelegate( - initialQuery = "", - pages = mapOf( - searchKey(query = "", offset = 0) to ContactsPage( - contacts = persistentListOf(firstContact), - nextOffset = 1, - ), - searchKey(query = "", offset = 1) to ContactsPage( - contacts = persistentListOf(secondContact), - nextOffset = null, + fun loadMore_nextPage_appendsItems() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstContact = contact( + id = 31L, + displayName = "Alpha", + destinations = listOf(destination(value = "+11111111", contactId = 31L)), + ) + val secondContact = contact( + id = 32L, + displayName = "Beta", + destinations = listOf(destination(value = "+22222222", contactId = 32L)), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf( + searchKey(query = "", offset = 0) to ContactsPage( + contacts = persistentListOf(firstContact), + nextOffset = 1, + ), + searchKey(query = "", offset = 1) to ContactsPage( + contacts = persistentListOf(secondContact), + nextOffset = null, + ), ), - ), - ) + ) - bindAndAwait(delegate = delegate) - delegate.onLoadMore() - testScheduler.advanceTimeBy(delayTimeMillis = 1_000L) - testScheduler.runCurrent() + bindAndAwait(delegate = delegate) + delegate.onLoadMore() + testScheduler.advanceTimeBy(delayTimeMillis = 1_000L) + testScheduler.runCurrent() - val finalState = delegate.state.value - val contactItems = finalState.items.filterIsInstance() - assertEquals(listOf(31L, 32L), contactItems.map { it.contact.id }) - assertFalse(finalState.canLoadMore) + val finalState = delegate.state.value + val contactItems = finalState.items.filterIsInstance() + assertEquals(listOf(31L, 32L), contactItems.map { it.contact.id }) + assertFalse(finalState.canLoadMore) + } } @Test - fun missingContactsPermissionEmitsEmptyState() = runTest { - val delegate = createDelegate( - initialQuery = "", - pages = emptyMap(), - isPermissionGranted = false, - ) + fun items_missingContactsPermission_emitsEmptyState() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val delegate = createDelegate( + initialQuery = "", + pages = emptyMap(), + isPermissionGranted = false, + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - assertTrue(finalState.items.isEmpty()) - assertFalse(finalState.hasContactsPermission) - assertFalse(finalState.canLoadMore) + assertTrue(finalState.items.isEmpty()) + assertFalse(finalState.hasContactsPermission) + assertFalse(finalState.canLoadMore) + } } @Test - fun syntheticPhoneAppearsWhenQueryHasDigitsAndNoMatchingDestination() = runTest { - val contactWithDifferentNumber = contact( - id = 41L, - displayName = "Some Person", - destinations = listOf(destination(value = "+19999999", contactId = 41L)), - ) - val delegate = createDelegate( - initialQuery = "5550001", - pages = mapOf( - searchKey(query = "5550001", offset = 0) to pageOf(contactWithDifferentNumber), - ), - ) + fun syntheticPhone_queryHasDigitsAndNoMatchingDestination_appears() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val contactWithDifferentNumber = contact( + id = 41L, + displayName = "Some Person", + destinations = listOf(destination(value = "+19999999", contactId = 41L)), + ) + val delegate = createDelegate( + initialQuery = "5550001", + pages = mapOf( + searchKey(query = "5550001", offset = 0) to pageOf(contactWithDifferentNumber), + ), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val syntheticItem = finalState.items - .filterIsInstance() - .single() - assertEquals("5550001", syntheticItem.destination) - assertEquals(2, finalState.items.size) + val syntheticItem = finalState.items + .filterIsInstance() + .single() + assertEquals("5550001", syntheticItem.destination) + assertEquals(2, finalState.items.size) + } } @Test - fun syntheticPhoneSuppressedWhenContactHasMatchingDestination() = runTest { - val contactWithMatchingNumber = contact( - id = 51L, - displayName = "Match", - destinations = listOf(destination(value = "5550001", contactId = 51L)), - ) - val delegate = createDelegate( - initialQuery = "5550001", - pages = mapOf( - searchKey(query = "5550001", offset = 0) to pageOf(contactWithMatchingNumber), - ), - ) + fun syntheticPhone_contactHasMatchingDestination_isSuppressed() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val contactWithMatchingNumber = contact( + id = 51L, + displayName = "Match", + destinations = listOf(destination(value = "5550001", contactId = 51L)), + ) + val delegate = createDelegate( + initialQuery = "5550001", + pages = mapOf( + searchKey(query = "5550001", offset = 0) to pageOf(contactWithMatchingNumber), + ), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val syntheticItems = finalState.items - .filterIsInstance() - assertTrue(syntheticItems.isEmpty()) - assertEquals(1, finalState.items.size) + val syntheticItems = finalState.items + .filterIsInstance() + assertTrue(syntheticItems.isEmpty()) + assertEquals(1, finalState.items.size) + } } @Test - fun syntheticPhoneDisplayNameComesFromSimCountryFormatter() = runTest { - every { - phoneUtilsInstance.formatForDisplayUsingSimCountry("5550123") - } returns "(555) 012-3" - - val delegate = createDelegate( - initialQuery = "5550123", - pages = mapOf(searchKey(query = "5550123", offset = 0) to emptyPage()), - ) + fun syntheticPhoneDisplayName_simCountryFormatter_formatsValue() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + every { + phoneUtilsInstance.formatForDisplayUsingSimCountry("5550123") + } returns "(555) 012-3" + + val delegate = createDelegate( + initialQuery = "5550123", + pages = mapOf(searchKey(query = "5550123", offset = 0) to emptyPage()), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val syntheticItem = finalState.items - .filterIsInstance() - .single() - assertEquals("(555) 012-3", syntheticItem.displayName) + val syntheticItem = finalState.items + .filterIsInstance() + .single() + assertEquals("(555) 012-3", syntheticItem.displayName) + } } @Test - fun syntheticPhoneSecondaryTextComesFromNormalizedDestinationFormatter() = runTest { - every { - phoneUtilsInstance.formatNormalizedDestinationUsingSimCountry("5550456") - } returns "+15550456" - - val delegate = createDelegate( - initialQuery = "5550456", - pages = mapOf(searchKey(query = "5550456", offset = 0) to emptyPage()), - ) + fun syntheticPhoneSecondaryText_normalizedDestinationFormatter_formatsValue() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + every { + phoneUtilsInstance.formatNormalizedDestinationUsingSimCountry("5550456") + } returns "+15550456" + + val delegate = createDelegate( + initialQuery = "5550456", + pages = mapOf(searchKey(query = "5550456", offset = 0) to emptyPage()), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val syntheticItem = finalState.items - .filterIsInstance() - .single() - assertEquals("+15550456", syntheticItem.secondaryText) + val syntheticItem = finalState.items + .filterIsInstance() + .single() + assertEquals("+15550456", syntheticItem.secondaryText) + } } @Test - fun syntheticPhoneTextFieldsFallBackToEmptyWhenFormatterReturnsNull() = runTest { - every { - phoneUtilsInstance.formatForDisplayUsingSimCountry(any()) - } returns null - every { - phoneUtilsInstance.formatNormalizedDestinationUsingSimCountry(any()) - } returns null - - val delegate = createDelegate( - initialQuery = "5550789", - pages = mapOf(searchKey(query = "5550789", offset = 0) to emptyPage()), - ) + fun syntheticPhoneTextFields_formatterReturnsNull_fallBackToEmpty() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + every { + phoneUtilsInstance.formatForDisplayUsingSimCountry(any()) + } returns null + every { + phoneUtilsInstance.formatNormalizedDestinationUsingSimCountry(any()) + } returns null + + val delegate = createDelegate( + initialQuery = "5550789", + pages = mapOf(searchKey(query = "5550789", offset = 0) to emptyPage()), + ) - val finalState = bindAndAwait(delegate = delegate) + val finalState = bindAndAwait(delegate = delegate) - val syntheticItem = finalState.items - .filterIsInstance() - .single() - assertEquals("", syntheticItem.displayName) - assertEquals("", syntheticItem.secondaryText) + val syntheticItem = finalState.items + .filterIsInstance() + .single() + assertEquals("", syntheticItem.displayName) + assertEquals("", syntheticItem.secondaryText) + } } private fun TestScope.bindAndAwait( @@ -392,7 +447,7 @@ internal class RecipientPickerDelegateImplTest { savedStateHandle = SavedStateHandle( initialState = mapOf("search_query" to initialQuery), ), - defaultDispatcher = UnconfinedTestDispatcher(scheduler = testScheduler), + defaultDispatcher = mainDispatcherRule.testDispatcher, ) } @@ -414,7 +469,7 @@ internal class RecipientPickerDelegateImplTest { flowOf(page) } - return contactsRepository + return contactsRepository.also { capturedContactsRepository = it } } private fun mockIsReadContactsPermissionGranted( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/BaseConversationResolutionDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/BaseConversationResolutionDelegateTest.kt new file mode 100644 index 000000000..58d6c988c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/BaseConversationResolutionDelegateTest.kt @@ -0,0 +1,56 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.conversationresolutiondelegate + +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversation.recipientpicker.delegate.ConversationResolutionDelegateImpl +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationResolutionDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected val resolveConversationId = mockk() + + @Before + fun setUpDefaultStubs() { + coEvery { + resolveConversationId(destinations = any()) + } returns ResolveConversationIdResult.Resolved(conversationId = DEFAULT_CONVERSATION_ID) + } + + protected fun createDelegate(): ConversationResolutionDelegateImpl { + return ConversationResolutionDelegateImpl( + resolveConversationId = resolveConversationId, + mainDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + protected fun TestScope.createBoundDelegate(): ConversationResolutionDelegateImpl { + return createDelegate().also { delegate -> + delegate.bind(scope = backgroundScope) + runCurrent() + } + } + + protected fun givenResolutionSuspendsUntil( + gate: CompletableDeferred, + ) { + coEvery { + resolveConversationId(destinations = any()) + } coAnswers { gate.await() } + } + + protected companion object { + const val DEFAULT_CONVERSATION_ID = "conversation-default" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateLifecycleTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateLifecycleTest.kt new file mode 100644 index 000000000..f3c215dac --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateLifecycleTest.kt @@ -0,0 +1,187 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.conversationresolutiondelegate + +import app.cash.turbine.test +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionOutcome +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import io.mockk.coEvery +import io.mockk.coVerify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationResolutionDelegateLifecycleTest : + BaseConversationResolutionDelegateTest() { + + @Test + fun resolve_beforeBind_isInertAndNeverInvokesUseCase() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = createDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + expectNoEvents() + } + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + coVerify(exactly = 0) { + resolveConversationId(destinations = any()) + } + } + } + + @Test + fun bind_isIdempotentAndRetainsTheFirstScope() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = createDelegate() + delegate.bind(scope = backgroundScope) + val replacementScope = CoroutineScope(context = mainDispatcherRule.testDispatcher) + replacementScope.cancel() + + delegate.bind(scope = replacementScope) + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + assertEquals( + ConversationResolutionOutcome.Resolved( + conversationId = DEFAULT_CONVERSATION_ID, + ), + awaitItem(), + ) + } + } + } + + @Test + fun cancel_whileResolving_returnsToIdleWithoutEmittingAnOutcome() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolutionSuspendsUntil(gate = CompletableDeferred()) + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve( + destinations = listOf("+15550100"), + recipientDestination = "+1 555 0100", + ) + runCurrent() + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = "+1 555 0100", + isIndicatorVisible = false, + ), + delegate.state.value, + ) + + delegate.cancel() + runCurrent() + + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + expectNoEvents() + } + } + } + + @Test + fun cancel_whenNothingIsResolving_isANoOp() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.cancel() + runCurrent() + + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + expectNoEvents() + } + coVerify(exactly = 0) { + resolveConversationId(destinations = any()) + } + } + } + + @Test + fun cancel_afterAReentrantResolve_stillCancelsTheLatestResolveAndEmitsNoOutcome() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstGate = CompletableDeferred() + val secondGate = CompletableDeferred() + coEvery { + resolveConversationId(destinations = listOf("first")) + } coAnswers { firstGate.await() } + coEvery { + resolveConversationId(destinations = listOf("second")) + } coAnswers { secondGate.await() } + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("first"), recipientDestination = "first") + runCurrent() + delegate.resolve(destinations = listOf("second"), recipientDestination = "second") + runCurrent() + + delegate.cancel() + runCurrent() + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + + secondGate.complete( + ResolveConversationIdResult.Resolved(conversationId = "conversation-second"), + ) + runCurrent() + + expectNoEvents() + } + } + } + + @Test + fun resolve_whileAnEarlierResolveIsInFlight_cancelsItAndEmitsOnlyTheLatestOutcome() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstGate = CompletableDeferred() + val secondGate = CompletableDeferred() + coEvery { + resolveConversationId(destinations = listOf("first")) + } coAnswers { firstGate.await() } + coEvery { + resolveConversationId(destinations = listOf("second")) + } coAnswers { secondGate.await() } + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("first"), recipientDestination = "first") + runCurrent() + delegate.resolve(destinations = listOf("second"), recipientDestination = "second") + runCurrent() + + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = "second", + isIndicatorVisible = false, + ), + delegate.state.value, + ) + + secondGate.complete( + ResolveConversationIdResult.Resolved(conversationId = "conversation-second"), + ) + runCurrent() + + assertEquals( + ConversationResolutionOutcome.Resolved( + conversationId = "conversation-second", + ), + awaitItem(), + ) + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + expectNoEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateOutcomeTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateOutcomeTest.kt new file mode 100644 index 000000000..9dec95b52 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateOutcomeTest.kt @@ -0,0 +1,113 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.conversationresolutiondelegate + +import app.cash.turbine.test +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionOutcome +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import io.mockk.coEvery +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationResolutionDelegateOutcomeTest : + BaseConversationResolutionDelegateTest() { + + @Test + fun resolve_whenUseCaseResolves_emitsResolvedOutcomeCarryingConversationId() { + runTest(context = mainDispatcherRule.testDispatcher) { + coEvery { + resolveConversationId(destinations = listOf("+15550100")) + } returns ResolveConversationIdResult.Resolved(conversationId = "conversation-42") + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + assertEquals( + ConversationResolutionOutcome.Resolved(conversationId = "conversation-42"), + awaitItem(), + ) + expectNoEvents() + } + } + } + + @Test + fun resolve_whenUseCaseReportsNotResolved_emitsFailedOutcome() { + runTest(context = mainDispatcherRule.testDispatcher) { + coEvery { + resolveConversationId(destinations = any()) + } returns ResolveConversationIdResult.NotResolved + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + assertEquals(ConversationResolutionOutcome.Failed, awaitItem()) + expectNoEvents() + } + } + } + + @Test + fun resolve_whenUseCaseReportsEmptyDestinations_emitsFailedOutcome() { + runTest(context = mainDispatcherRule.testDispatcher) { + coEvery { + resolveConversationId(destinations = any()) + } returns ResolveConversationIdResult.EmptyDestinations + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf(" ")) + runCurrent() + + assertEquals(ConversationResolutionOutcome.Failed, awaitItem()) + expectNoEvents() + } + } + } + + @Test + fun resolve_whenUseCaseThrows_emitsFailedOutcomeAndReturnsToIdle() { + runTest(context = mainDispatcherRule.testDispatcher) { + coEvery { + resolveConversationId(destinations = any()) + } throws IllegalStateException("boom") + val delegate = createBoundDelegate() + + delegate.outcomes.test { + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + assertEquals(ConversationResolutionOutcome.Failed, awaitItem()) + expectNoEvents() + } + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + } + } + + @Test + fun resolve_forwardsDestinationsToUseCaseWithoutTrimmingOrFiltering() { + runTest(context = mainDispatcherRule.testDispatcher) { + val captured = slot>() + coEvery { + resolveConversationId(destinations = capture(captured)) + } returns ResolveConversationIdResult.Resolved(conversationId = "conversation-42") + val delegate = createBoundDelegate() + + delegate.resolve(destinations = listOf(" +15550100 ", "alice@example.com", "")) + runCurrent() + + assertEquals( + listOf(" +15550100 ", "alice@example.com", ""), + captured.captured, + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateStateTest.kt new file mode 100644 index 000000000..d0c3dbd47 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/conversationresolutiondelegate/ConversationResolutionDelegateStateTest.kt @@ -0,0 +1,148 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.conversationresolutiondelegate + +import app.cash.turbine.test +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.ui.conversation.recipientpicker.model.picker.ConversationResolutionState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationResolutionDelegateStateTest : BaseConversationResolutionDelegateTest() { + + @Test + fun resolve_fromIdle_emitsResolvingWithHiddenIndicatorAndRecipientDestination() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolutionSuspendsUntil(gate = CompletableDeferred()) + val delegate = createBoundDelegate() + + delegate.state.test { + assertEquals(ConversationResolutionState.Idle, awaitItem()) + + delegate.resolve( + destinations = listOf("+15550100"), + recipientDestination = "+1 555 0100", + ) + runCurrent() + + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = "+1 555 0100", + isIndicatorVisible = false, + ), + awaitItem(), + ) + } + } + } + + @Test + fun resolve_withoutRecipientDestination_carriesNullIntoResolvingState() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolutionSuspendsUntil(gate = CompletableDeferred()) + val delegate = createBoundDelegate() + + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = null, + isIndicatorVisible = false, + ), + delegate.state.value, + ) + } + } + + @Test + fun resolve_keepsIndicatorHiddenUntilDelayElapsesThenRevealsItWhileStillResolving() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenResolutionSuspendsUntil(gate = CompletableDeferred()) + val delegate = createBoundDelegate() + delegate.resolve( + destinations = listOf("+15550100"), + recipientDestination = "+1 555 0100", + ) + runCurrent() + + advanceTimeBy(INDICATOR_DELAY_MILLIS - 1) + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = "+1 555 0100", + isIndicatorVisible = false, + ), + delegate.state.value, + ) + + advanceTimeBy(2) + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = "+1 555 0100", + isIndicatorVisible = true, + ), + delegate.state.value, + ) + } + } + + @Test + fun resolve_completingBeforeIndicatorDelay_neverRevealsIndicator() { + runTest(context = mainDispatcherRule.testDispatcher) { + val gate = CompletableDeferred() + givenResolutionSuspendsUntil(gate = gate) + val delegate = createBoundDelegate() + + delegate.state.test { + assertEquals(ConversationResolutionState.Idle, awaitItem()) + + delegate.resolve( + destinations = listOf("+15550100"), + recipientDestination = "+1 555 0100", + ) + runCurrent() + assertEquals( + ConversationResolutionState.Resolving( + recipientDestination = "+1 555 0100", + isIndicatorVisible = false, + ), + awaitItem(), + ) + + gate.complete( + ResolveConversationIdResult.Resolved(conversationId = "conversation-42"), + ) + runCurrent() + + assertEquals(ConversationResolutionState.Idle, awaitItem()) + expectNoEvents() + } + } + } + + @Test + fun resolve_whenResolutionCompletes_returnsToIdle() { + runTest(context = mainDispatcherRule.testDispatcher) { + val gate = CompletableDeferred() + givenResolutionSuspendsUntil(gate = gate) + val delegate = createBoundDelegate() + delegate.resolve(destinations = listOf("+15550100")) + runCurrent() + + gate.complete( + ResolveConversationIdResult.Resolved(conversationId = "conversation-42"), + ) + runCurrent() + + assertEquals(ConversationResolutionState.Idle, delegate.state.value) + } + } + + private companion object { + const val INDICATOR_DELAY_MILLIS = 200L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/BaseSelectedRecipientsDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/BaseSelectedRecipientsDelegateTest.kt new file mode 100644 index 000000000..a712081f9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/BaseSelectedRecipientsDelegateTest.kt @@ -0,0 +1,49 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.selectedrecipientsdelegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.ui.conversation.recipientpicker.delegate.SelectedRecipientsDelegateImpl +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import io.mockk.spyk + +internal abstract class BaseSelectedRecipientsDelegateTest { + + protected lateinit var savedStateHandle: SavedStateHandle + + protected fun createDelegate( + initialRecipients: List = emptyList(), + ): SelectedRecipientsDelegateImpl { + savedStateHandle = spyk( + SavedStateHandle( + initialState = when { + initialRecipients.isEmpty() -> emptyMap() + else -> mapOf(SELECTED_RECIPIENTS_KEY to ArrayList(initialRecipients)) + }, + ), + ) + return SelectedRecipientsDelegateImpl(savedStateHandle = savedStateHandle) + } + + protected fun recipient( + destination: String, + label: String = "Label $destination", + displayDestination: String = "Display $destination", + photoUri: String? = null, + ): SelectedRecipient { + return SelectedRecipient( + destination = destination, + label = label, + displayDestination = displayDestination, + photoUri = photoUri, + ) + } + + protected fun persistedRecipients(): List? { + return savedStateHandle + .get>(SELECTED_RECIPIENTS_KEY) + ?.toList() + } + + protected companion object { + const val SELECTED_RECIPIENTS_KEY = "selected_recipients" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateMutationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateMutationTest.kt new file mode 100644 index 000000000..6c1dbcf3b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateMutationTest.kt @@ -0,0 +1,119 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.selectedrecipientsdelegate + +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class SelectedRecipientsDelegateMutationTest : BaseSelectedRecipientsDelegateTest() { + + @Test + fun replaceWith_fromEmpty_setsStateToSingleRecipient() { + val delegate = createDelegate() + val replacement = recipient(destination = "+15550001") + + delegate.replaceWith(recipient = replacement) + + assertEquals(listOf(replacement), delegate.state.value.toList()) + assertEquals(listOf(replacement), persistedRecipients()) + } + + @Test + fun replaceWith_whenRecipientsExist_discardsPreviousSelection() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + ), + ) + + delegate.replaceWith(recipient = recipient(destination = "+15550003")) + + assertEquals(listOf("+15550003"), delegate.state.value.map { it.destination }) + assertEquals(listOf("+15550003"), persistedRecipients()?.map { it.destination }) + } + + @Test + fun clear_whenNotEmpty_emptiesStateAndPersistsEmptyList() { + val delegate = createDelegate( + initialRecipients = listOf(recipient(destination = "+15550001")), + ) + + delegate.clear() + + assertTrue(delegate.state.value.isEmpty()) + assertTrue(persistedRecipients()?.isEmpty() == true) + } + + @Test + fun clear_whenAlreadyEmpty_doesNotPersist() { + val delegate = createDelegate() + + delegate.clear() + + assertTrue(delegate.state.value.isEmpty()) + assertNull(persistedRecipients()) + verify(exactly = 0) { + savedStateHandle.set(SELECTED_RECIPIENTS_KEY, any>()) + } + } + + @Test + fun removeWhere_matchingPredicate_removesMatchesAndPersistsRemainder() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + recipient(destination = "+15550003"), + ), + ) + + delegate.removeWhere { it.destination == "+15550002" } + + assertEquals( + listOf("+15550001", "+15550003"), + delegate.state.value.map { it.destination }, + ) + assertEquals( + listOf("+15550001", "+15550003"), + persistedRecipients()?.map { it.destination }, + ) + } + + @Test + fun removeWhere_predicateMatchesNothing_doesNotMutateOrPersist() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + ), + ) + + delegate.removeWhere { it.destination == "+19999999" } + + assertEquals( + listOf("+15550001", "+15550002"), + delegate.state.value.map { it.destination }, + ) + verify(exactly = 0) { + savedStateHandle.set(SELECTED_RECIPIENTS_KEY, any>()) + } + } + + @Test + fun removeWhere_predicateMatchesAll_emptiesStateAndPersistsEmptyList() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + ), + ) + + delegate.removeWhere { true } + + assertTrue(delegate.state.value.isEmpty()) + assertTrue(persistedRecipients()?.isEmpty() == true) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateRestorationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateRestorationTest.kt new file mode 100644 index 000000000..baae28728 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateRestorationTest.kt @@ -0,0 +1,57 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.selectedrecipientsdelegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.ui.conversation.recipientpicker.delegate.SelectedRecipientsDelegateImpl +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class SelectedRecipientsDelegateRestorationTest : BaseSelectedRecipientsDelegateTest() { + + @Test + fun state_whenNoSavedRecipients_startsEmpty() { + val delegate = createDelegate() + + assertTrue(delegate.state.value.isEmpty()) + } + + @Test + fun state_whenSavedRecipientsExist_restoresThemInOrder() { + val saved = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + ) + val delegate = createDelegate(initialRecipients = saved) + + assertEquals(saved, delegate.state.value.toList()) + } + + @Test + fun state_whenSavedListPersistedEmpty_startsEmpty() { + savedStateHandle = spyk( + SavedStateHandle( + initialState = mapOf(SELECTED_RECIPIENTS_KEY to ArrayList()), + ), + ) + val delegate = SelectedRecipientsDelegateImpl(savedStateHandle = savedStateHandle) + + assertTrue(delegate.state.value.isEmpty()) + } + + @Test + fun mutation_persistsUnderSelectedRecipientsKeyAsArrayList() { + val delegate = createDelegate() + val replacement = recipient(destination = "+15550001") + + delegate.replaceWith(recipient = replacement) + + val persisted = slot() + verify { savedStateHandle.set(SELECTED_RECIPIENTS_KEY, capture(persisted)) } + assertTrue(persisted.captured is ArrayList<*>) + assertEquals(listOf(replacement), (persisted.captured as ArrayList<*>).toList()) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateToggleTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateToggleTest.kt new file mode 100644 index 000000000..882cc522c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/selectedrecipientsdelegate/SelectedRecipientsDelegateToggleTest.kt @@ -0,0 +1,139 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate.selectedrecipientsdelegate + +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientToggleOutcome +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class SelectedRecipientsDelegateToggleTest : BaseSelectedRecipientsDelegateTest() { + + @Test + fun toggle_newRecipientWithinLimit_addsRecipientAndReturnsAdded() { + val delegate = createDelegate() + val added = recipient(destination = "+15550001") + + val outcome = delegate.toggle(recipient = added, canAdd = { true }) + + assertEquals(RecipientToggleOutcome.Added, outcome) + assertEquals(listOf(added), delegate.state.value.toList()) + assertEquals(listOf(added), persistedRecipients()) + } + + @Test + fun toggle_secondRecipient_appendsPreservingInsertionOrder() { + val delegate = createDelegate( + initialRecipients = listOf(recipient(destination = "+15550001")), + ) + + val outcome = delegate.toggle( + recipient = recipient(destination = "+15550002"), + canAdd = { true }, + ) + + assertEquals(RecipientToggleOutcome.Added, outcome) + assertEquals( + listOf("+15550001", "+15550002"), + delegate.state.value.map { it.destination }, + ) + assertEquals( + listOf("+15550001", "+15550002"), + persistedRecipients()?.map { it.destination }, + ) + } + + @Test + fun toggle_alreadySelectedDestination_removesItAndReturnsRemoved() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + ), + ) + + val outcome = delegate.toggle( + recipient = recipient(destination = "+15550001"), + canAdd = { true }, + ) + + assertEquals(RecipientToggleOutcome.Removed, outcome) + assertEquals(listOf("+15550002"), delegate.state.value.map { it.destination }) + assertEquals(listOf("+15550002"), persistedRecipients()?.map { it.destination }) + } + + @Test + fun toggle_alreadySelectedDestination_doesNotConsultCanAdd() { + val existing = recipient(destination = "+15550001") + val delegate = createDelegate(initialRecipients = listOf(existing)) + val canAdd = mockk<(Int) -> Boolean>() + + val outcome = delegate.toggle(recipient = existing, canAdd = canAdd) + + assertEquals(RecipientToggleOutcome.Removed, outcome) + verify(exactly = 0) { canAdd(any()) } + } + + @Test + fun toggle_sameDestinationDifferentFields_matchesByDestinationAndRemoves() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient( + destination = "+15550001", + label = "Alice", + displayDestination = "Alice Home", + photoUri = "content://photo/alice", + ), + ), + ) + + val outcome = delegate.toggle( + recipient = recipient( + destination = "+15550001", + label = "Bob", + displayDestination = "Bob Work", + photoUri = null, + ), + canAdd = { true }, + ) + + assertEquals(RecipientToggleOutcome.Removed, outcome) + assertTrue(delegate.state.value.isEmpty()) + } + + @Test + fun toggle_canAddRejectsProspectiveSize_returnsOverLimitWithoutMutating() { + val delegate = createDelegate( + initialRecipients = listOf(recipient(destination = "+15550001")), + ) + + val outcome = delegate.toggle( + recipient = recipient(destination = "+15550002"), + canAdd = { false }, + ) + + assertEquals(RecipientToggleOutcome.OverLimit, outcome) + assertEquals(listOf("+15550001"), delegate.state.value.map { it.destination }) + verify(exactly = 0) { + savedStateHandle[SELECTED_RECIPIENTS_KEY] = any>() + } + } + + @Test + fun toggle_addingToExistingSelection_passesProspectiveSizeToCanAdd() { + val delegate = createDelegate( + initialRecipients = listOf( + recipient(destination = "+15550001"), + recipient(destination = "+15550002"), + ), + ) + val canAdd = mockk<(Int) -> Boolean>() + every { canAdd(any()) } returns true + + delegate.toggle(recipient = recipient(destination = "+15550003"), canAdd = canAdd) + + verify { canAdd(3) } + } +} From 986de76dbf391980c48c4ac859cdb37d129be70a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:38:07 +0300 Subject: [PATCH 08/38] Add conversation media unit tests --- ...versationMediaThumbnailBitmapLoaderTest.kt | 196 +++++ .../ConversationAudioDurationFormatterTest.kt | 21 + ...versationAudioRecordingDelegateImplTest.kt | 503 +++++++++++++ .../ConversationFocusDelegateImplTest.kt | 237 ++++++ .../ConversationMediaPickerStateTest.kt | 86 +++ .../ConversationMediaPickerActionsTest.kt | 169 +++++ .../ConversationMediaReviewBitmapCacheTest.kt | 48 ++ ...BaseConversationMediaPickerDelegateTest.kt | 74 ++ ...ionMediaPickerDelegateDirectSourcesTest.kt | 145 ++++ ...ationMediaPickerDelegatePhotoPickerTest.kt | 431 +++++++++++ ...versationMediaPickerDelegateRemovalTest.kt | 118 +++ ...nversationDraftAttachmentMapperImplTest.kt | 35 + ...nversationAttachmentsRepositoryImplTest.kt | 677 ++++++++++++++++++ .../ConversationMetadataDelegateImplTest.kt | 295 ++++++++ ...nversationMetadataUiStateMapperImplTest.kt | 79 ++ 15 files changed, 3114 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/thumbnail/ConversationMediaThumbnailBitmapLoaderTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatterTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegateImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegateImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActionsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCacheTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/BaseConversationMediaPickerDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateDirectSourcesTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegatePhotoPickerTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateRemovalTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapperImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/repository/ConversationAttachmentsRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/thumbnail/ConversationMediaThumbnailBitmapLoaderTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/thumbnail/ConversationMediaThumbnailBitmapLoaderTest.kt new file mode 100644 index 000000000..489004533 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/thumbnail/ConversationMediaThumbnailBitmapLoaderTest.kt @@ -0,0 +1,196 @@ +package com.android.messaging.ui.conversation.attachment.ui.thumbnail + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import androidx.compose.ui.unit.IntSize +import com.android.messaging.ui.conversation.attachment.ui.loadConversationMediaThumbnailBitmap +import com.android.messaging.util.MediaMetadataRetrieverWrapper +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaThumbnailBitmapLoaderTest { + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun loadBitmap_returnsPlatformThumbnailWhenAvailable() { + runTest { + val contentResolver = mockk(relaxed = true) + val contentUri = Uri.parse("content://media/image/1") + val platformBitmap = createBitmap(width = 24, height = 12) + val capturedSize = slot() + every { + contentResolver.loadThumbnail(contentUri, capture(capturedSize), null) + } returns platformBitmap + + val result = loadConversationMediaThumbnailBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = "image/png", + size = IntSize(width = 64, height = 32), + softenBitmap = false, + ) + + assertSame(platformBitmap, result) + assertEquals(Size(64, 32), capturedSize.captured) + verify(exactly = 0) { + contentResolver.openInputStream(any()) + } + } + } + + @Test + fun loadBitmap_fallsBackToImageDecodeWhenPlatformThumbnailFails() { + runTest { + val contentResolver = mockk() + val contentUri = Uri.parse("content://media/image/2") + val imageBytes = createPngBytes(width = 80, height = 40) + every { + contentResolver.loadThumbnail(contentUri, any(), null) + } throws IOException("thumbnail unavailable") + every { + contentResolver.openInputStream(contentUri) + } answers { + ByteArrayInputStream(imageBytes) + } + + val result = loadConversationMediaThumbnailBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = "image/png", + size = IntSize(width = 20, height = 20), + softenBitmap = false, + ) + + assertNotNull(result) + assertEquals(40, result?.width) + assertEquals(20, result?.height) + verify(exactly = 2) { + contentResolver.openInputStream(contentUri) + } + } + } + + @Test + fun loadBitmap_fallsBackToVideoFrameAndScalesDown() { + runTest { + val contentResolver = mockk() + val contentUri = Uri.parse("content://media/video/1") + val videoFrame = createBitmap(width = 200, height = 100) + every { + contentResolver.loadThumbnail(contentUri, any(), null) + } throws IOException("thumbnail unavailable") + mockkConstructor(MediaMetadataRetrieverWrapper::class) + every { + anyConstructed().setDataSource(contentUri) + } just runs + every { + anyConstructed().frameAtTime + } returns videoFrame + every { + anyConstructed().release() + } just runs + + val result = loadConversationMediaThumbnailBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = "video/mp4", + size = IntSize(width = 50, height = 50), + softenBitmap = false, + ) + + assertNotSame(videoFrame, result) + assertEquals(50, result?.width) + assertEquals(25, result?.height) + verify(exactly = 1) { + anyConstructed().release() + } + } + } + + @Test + fun loadBitmap_returnsNullForUnsupportedContentWhenThumbnailUnavailable() { + runTest { + val contentResolver = mockk(relaxed = true) + val contentUri = Uri.parse("content://media/file/1") + every { + contentResolver.loadThumbnail(contentUri, any(), null) + } throws IOException("thumbnail unavailable") + + val result = loadConversationMediaThumbnailBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = "application/pdf", + size = IntSize(width = 32, height = 32), + softenBitmap = false, + ) + + assertNull(result) + verify(exactly = 0) { + contentResolver.openInputStream(any()) + } + } + } + + @Test + fun loadBitmap_softensThumbnailIntoRequestedOutputSize() { + runTest { + val contentResolver = mockk() + val contentUri = Uri.parse("content://media/image/3") + val platformBitmap = createBitmap(width = 80, height = 20) + every { + contentResolver.loadThumbnail(contentUri, any(), null) + } returns platformBitmap + + val result = loadConversationMediaThumbnailBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = "image/png", + size = IntSize(width = 60, height = 30), + softenBitmap = true, + ) + + assertNotSame(platformBitmap, result) + assertEquals(60, result?.width) + assertEquals(30, result?.height) + } + } + + private fun createBitmap(width: Int, height: Int): Bitmap { + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + } + + @Suppress("SameParameterValue") + private fun createPngBytes(width: Int, height: Int): ByteArray { + val stream = ByteArrayOutputStream() + createBitmap(width = width, height = height) + .compress(Bitmap.CompressFormat.PNG, 100, stream) + + return stream.toByteArray() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatterTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatterTest.kt new file mode 100644 index 000000000..60dd1d09f --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatterTest.kt @@ -0,0 +1,21 @@ +package com.android.messaging.ui.conversation.audio + +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ConversationAudioDurationFormatterTest { + + @Test + fun formatConversationAudioDuration_truncatesSubSecondDurations() { + assertEquals("00:00", formatConversationAudioDuration(durationMillis = 0L)) + assertEquals("00:00", formatConversationAudioDuration(durationMillis = 999L)) + } + + @Test + fun formatConversationAudioDuration_formatsMinutesAndSeconds() { + assertEquals("00:01", formatConversationAudioDuration(durationMillis = 1_000L)) + assertEquals("01:01", formatConversationAudioDuration(durationMillis = 61_000L)) + assertEquals("59:59", formatConversationAudioDuration(durationMillis = 3_599_000L)) + assertEquals("60:00", formatConversationAudioDuration(durationMillis = 3_600_000L)) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegateImplTest.kt new file mode 100644 index 000000000..ce30800b8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegateImplTest.kt @@ -0,0 +1,503 @@ +package com.android.messaging.ui.conversation.audio.delegate + +import android.net.Uri +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder +import com.android.messaging.util.ContentType +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkConstructor +import io.mockk.verify +import java.time.Duration +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowSystemClock + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationAudioRecordingDelegateImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var conversationAttachmentsRepository: ConversationAttachmentsRepository + private lateinit var subscriptionsRepository: SubscriptionsRepository + private lateinit var conversationDraftDelegate: ConversationDraftDelegate + + @Before + fun setUp() { + conversationAttachmentsRepository = mockk() + subscriptionsRepository = mockk() + conversationDraftDelegate = mockk() + + every { + subscriptionsRepository.resolveMaxMessageSize(any()) + } returns flowOf(500_000) + every { + conversationAttachmentsRepository.deleteTemporaryAttachment(any()) + } returns flowOf(Unit) + every { + conversationDraftDelegate.addAttachments(any()) + } answers { + firstArg>().toList() + } + every { + conversationDraftDelegate.addPendingAttachment(any()) + } just runs + every { + conversationDraftDelegate.removePendingAttachment(any()) + } just runs + every { + conversationDraftDelegate.resolvePendingAttachment(any(), any()) + } returns true + } + + @After + fun tearDown() { + unmockkConstructor(LevelTrackingMediaRecorder::class) + } + + @Test + fun startRecording_startsRecorderAndPublishesRecordingState() { + runTest(context = mainDispatcherRule.testDispatcher) { + mockSuccessfulRecorderStart() + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Recording, + delegate.state.value.phase, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + subscriptionsRepository.resolveMaxMessageSize( + selfParticipantId = "self-1", + ) + } + verify(exactly = 1) { + anyConstructed().startRecording( + any(), + any(), + 500_000, + ) + } + } + } + + @Test + fun startLockedRecording_startsRecorderAndPublishesLockedRecordingState() { + runTest(context = mainDispatcherRule.testDispatcher) { + mockSuccessfulRecorderStart() + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startLockedRecording(selfParticipantId = "self-1") + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Recording, + delegate.state.value.phase, + ) + assertTrue(delegate.state.value.isLocked) + verify(exactly = 1) { + @Suppress("UnusedFlow") + subscriptionsRepository.resolveMaxMessageSize( + selfParticipantId = "self-1", + ) + } + verify(exactly = 1) { + anyConstructed().startRecording( + any(), + any(), + 500_000, + ) + } + } + } + + @Test + fun lockRecording_whileStarting_locksWhenRecorderStarts() { + runTest(context = mainDispatcherRule.testDispatcher) { + val maxMessageSize = CompletableDeferred() + every { + subscriptionsRepository.resolveMaxMessageSize(any()) + } returns flow { + emit(maxMessageSize.await()) + } + mockSuccessfulRecorderStart() + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + + assertTrue(delegate.lockRecording()) + maxMessageSize.complete(500_000) + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Recording, + delegate.state.value.phase, + ) + assertTrue(delegate.state.value.isLocked) + } + } + + @Test + fun cancelRecording_whileStarting_stopsAndDeletesWhenRecorderStarts() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/starting") + val maxMessageSize = CompletableDeferred() + every { + subscriptionsRepository.resolveMaxMessageSize(any()) + } returns flow { + emit(maxMessageSize.await()) + } + mockSuccessfulRecorderStart(outputUri = outputUri) + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + delegate.cancelRecording() + maxMessageSize.complete(500_000) + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Idle, + delegate.state.value.phase, + ) + verify(exactly = 1) { + anyConstructed().stopRecording() + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationAttachmentsRepository.deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + } + } + } + + @Test + fun finishRecording_whileStarting_stopsAndDeletesWhenRecorderStarts() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/starting-finish") + val maxMessageSize = CompletableDeferred() + every { + subscriptionsRepository.resolveMaxMessageSize(any()) + } returns flow { + emit(maxMessageSize.await()) + } + mockSuccessfulRecorderStart(outputUri = outputUri) + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Recording, + delegate.state.value.phase, + ) + + delegate.finishRecording() + maxMessageSize.complete(500_000) + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Idle, + delegate.state.value.phase, + ) + verify(exactly = 1) { + anyConstructed().stopRecording() + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationAttachmentsRepository.deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + } + verify(exactly = 0) { + conversationDraftDelegate.addPendingAttachment(any()) + } + } + } + + @Test + fun finishRecording_afterMinimumDuration_resolvesPendingAudioAttachment() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/1") + val pendingAttachment = slot() + mockSuccessfulRecorderStart(outputUri = outputUri) + every { + conversationDraftDelegate.addPendingAttachment(capture(pendingAttachment)) + } just runs + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + ShadowSystemClock.advanceBy(Duration.ofMillis(350)) + + delegate.finishRecording() + runCurrent() + + assertEquals( + ConversationAudioRecordingPhase.Finalizing, + delegate.state.value.phase, + ) + verifyPendingAudioAttachmentAdded(pendingAttachment = pendingAttachment) + + advanceTimeBy(delayTimeMillis = 500L) + runCurrent() + + verify(exactly = 1) { + conversationDraftDelegate.resolvePendingAttachment( + pendingAttachmentId = pendingAttachment.captured.pendingAttachmentId, + attachment = ConversationDraftAttachment( + contentType = ContentType.AUDIO_3GPP, + contentUri = outputUri.toString(), + ), + ) + } + verify(exactly = 0) { + conversationDraftDelegate.addAttachments(any()) + } + assertEquals( + ConversationAudioRecordingPhase.Idle, + delegate.state.value.phase, + ) + } + } + + @Test + fun cancelRecording_whileRecording_deletesTemporaryAttachmentAndResetsState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/2") + mockSuccessfulRecorderStart(outputUri = outputUri) + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + delegate.cancelRecording() + runCurrent() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationAttachmentsRepository.deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + } + verify(exactly = 0) { + conversationDraftDelegate.addAttachments(any()) + } + assertEquals( + ConversationAudioRecordingPhase.Idle, + delegate.state.value.phase, + ) + } + } + + @Test + fun cancelRecording_whileFinalizing_removesPendingAttachmentAndDeletesTemporaryAttachment() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/finalizing") + val pendingAttachment = slot() + mockSuccessfulRecorderStart(outputUri = outputUri) + every { + conversationDraftDelegate.addPendingAttachment(capture(pendingAttachment)) + } just runs + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + ShadowSystemClock.advanceBy(Duration.ofMillis(350)) + delegate.finishRecording() + runCurrent() + + delegate.cancelRecording() + runCurrent() + + verify(exactly = 1) { + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachment.captured.pendingAttachmentId, + ) + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationAttachmentsRepository.deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + } + verify(exactly = 0) { + conversationDraftDelegate.resolvePendingAttachment(any(), any()) + } + assertEquals( + ConversationAudioRecordingPhase.Idle, + delegate.state.value.phase, + ) + } + } + + @Test + fun finishRecording_calledTwice_resolvesPendingAttachmentOnce() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/double-finish") + mockSuccessfulRecorderStart(outputUri = outputUri) + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + ShadowSystemClock.advanceBy(Duration.ofMillis(350)) + + delegate.finishRecording() + delegate.finishRecording() + advanceTimeBy(delayTimeMillis = 500L) + runCurrent() + + verify(exactly = 1) { + conversationDraftDelegate.addPendingAttachment(any()) + } + verify(exactly = 1) { + conversationDraftDelegate.resolvePendingAttachment(any(), any()) + } + verify(exactly = 1) { + anyConstructed().stopRecording() + } + } + } + + @Test + fun lockRecording_thenDurationTick_preservesLockedState() { + runTest(context = mainDispatcherRule.testDispatcher) { + mockSuccessfulRecorderStart() + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + + assertTrue(delegate.lockRecording()) + ShadowSystemClock.advanceBy(Duration.ofMillis(250)) + advanceTimeBy(delayTimeMillis = 200L) + runCurrent() + + assertTrue(delegate.state.value.isLocked) + assertTrue(delegate.state.value.durationMillis >= 250L) + } + } + + @Test + fun finishRecording_beforeMinimumDuration_deletesTemporaryAttachmentWithoutAttaching() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://scratch/audio/short") + mockSuccessfulRecorderStart(outputUri = outputUri) + + val delegate = createBoundDelegate(scope = backgroundScope) + + delegate.startRecording(selfParticipantId = "self-1") + runCurrent() + ShadowSystemClock.advanceBy(Duration.ofMillis(250)) + delegate.finishRecording() + runCurrent() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationAttachmentsRepository.deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + } + verify(exactly = 0) { + conversationDraftDelegate.addPendingAttachment(any()) + } + verify(exactly = 0) { + conversationDraftDelegate.resolvePendingAttachment(any(), any()) + } + assertEquals( + ConversationAudioRecordingPhase.Idle, + delegate.state.value.phase, + ) + } + } + + private fun mockSuccessfulRecorderStart( + outputUri: Uri = Uri.parse("content://scratch/audio/default"), + ) { + mockkConstructor(LevelTrackingMediaRecorder::class) + every { + anyConstructed().startRecording(any(), any(), 500_000) + } returns true + every { + anyConstructed().stopRecording() + } returns outputUri + } + + private fun verifyPendingAudioAttachmentAdded( + pendingAttachment: CapturingSlot, + ) { + assertTrue(pendingAttachment.isCaptured) + assertTrue( + pendingAttachment.captured.pendingAttachmentId.startsWith( + prefix = "pending-audio-", + ), + ) + assertEquals( + "pending://audio/${pendingAttachment.captured.pendingAttachmentId}", + pendingAttachment.captured.contentUri, + ) + assertEquals(ContentType.AUDIO_3GPP, pendingAttachment.captured.contentType) + assertEquals( + ConversationDraftPendingAttachmentKind.AudioFinalizing, + pendingAttachment.captured.kind, + ) + } + + private fun createBoundDelegate(scope: CoroutineScope): ConversationAudioRecordingDelegateImpl { + return ConversationAudioRecordingDelegateImpl( + conversationAttachmentsRepository = conversationAttachmentsRepository, + subscriptionsRepository = subscriptionsRepository, + conversationDraftDelegate = conversationDraftDelegate, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ).also { delegate -> + delegate.bind( + scope = scope, + conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID), + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegateImplTest.kt new file mode 100644 index 000000000..d159ec156 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegateImplTest.kt @@ -0,0 +1,237 @@ +package com.android.messaging.ui.conversation.focus.delegate + +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +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.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationFocusDelegateImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var dataModel: DataModel + + @Before + fun setUp() { + dataModel = mockk(relaxed = true) + mockkStatic(DataModel::class) + every { DataModel.get() } returns dataModel + mockkStatic(BugleNotifications::class) + every { + BugleNotifications.markMessagesAsRead(any(), any()) + } just runs + } + + @After + fun tearDown() { + unmockkStatic(DataModel::class) + unmockkStatic(BugleNotifications::class) + } + + @Test + fun setScreenFocused_withConversationId_setsFocusAndMarksRead() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true) + runCurrent() + + verify(exactly = 1) { + dataModel.setFocusedConversation(CONVERSATION_ID) + } + verify(exactly = 1) { + BugleNotifications.markMessagesAsRead( + CONVERSATION_ID, + true, + ) + } + } + } + + @Test + fun setScreenFocused_withCancelNotificationFalse_propagatesFlag() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true, cancelNotification = false) + runCurrent() + + verify(exactly = 1) { + BugleNotifications.markMessagesAsRead( + CONVERSATION_ID, + false, + ) + } + } + } + + @Test + fun setScreenFocused_withNullConversationId_doesNotMarkRead() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = null) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true) + runCurrent() + + verify(exactly = 0) { + BugleNotifications.markMessagesAsRead(any(), any()) + } + verify(exactly = 0) { + dataModel.setFocusedConversation(ofType()) + } + } + } + + @Test + fun setScreenFocused_withBlankConversationId_doesNotMarkRead() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = " ") + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true) + runCurrent() + + verify(exactly = 0) { + BugleNotifications.markMessagesAsRead(any(), any()) + } + } + } + + @Test + fun setScreenFocused_unfocused_clearsFocusedConversation() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val focusedConversationIds = mutableListOf() + every { + dataModel.setFocusedConversation(any()) + } answers { + focusedConversationIds.add(firstArg()) + } + every { + dataModel.setFocusedConversation(null) + } answers { + focusedConversationIds.add(null) + } + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true) + runCurrent() + delegate.setScreenFocused(focused = false) + runCurrent() + + assertEquals(listOf(null, CONVERSATION_ID, null), focusedConversationIds) + } + } + + @Test + fun conversationIdSwap_whileFocused_marksReadForNewConversation() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true) + runCurrent() + conversationIdFlow.value = "conversation-2" + runCurrent() + + verify(exactly = 1) { + BugleNotifications.markMessagesAsRead(CONVERSATION_ID, true) + } + verify(exactly = 1) { + BugleNotifications.markMessagesAsRead("conversation-2", true) + } + verify(exactly = 1) { + dataModel.setFocusedConversation("conversation-2") + } + } + } + + @Test + fun setScreenFocused_repeatedIdenticalFocusedRequests_marksReadOnce() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + + delegate.setScreenFocused(focused = true) + delegate.setScreenFocused(focused = true) + delegate.setScreenFocused(focused = true) + runCurrent() + + verify(exactly = 1) { + BugleNotifications.markMessagesAsRead(CONVERSATION_ID, true) + } + } + } + + @Test + fun bind_calledTwice_onlyBindsFirstScope() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val secondConversationIdFlow = MutableStateFlow(value = "conversation-rebound") + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + delegate.bind(scope = backgroundScope, conversationIdFlow = secondConversationIdFlow) + + delegate.setScreenFocused(focused = true) + runCurrent() + + verify(exactly = 1) { + BugleNotifications.markMessagesAsRead(CONVERSATION_ID, true) + } + verify(exactly = 0) { + BugleNotifications.markMessagesAsRead("conversation-rebound", any()) + } + } + } + + private fun TestScope.createBoundDelegate( + conversationIdFlow: MutableStateFlow, + ): ConversationFocusDelegateImpl { + val delegate = ConversationFocusDelegateImpl( + defaultDispatcher = mainDispatcherRule.testDispatcher, + ) + delegate.bind(scope = backgroundScope, conversationIdFlow = conversationIdFlow) + runCurrent() + return delegate + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerStateTest.kt new file mode 100644 index 000000000..e8fd410fe --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerStateTest.kt @@ -0,0 +1,86 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.runtime.saveable.SaverScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConversationMediaPickerStateTest { + + @Test + fun openShowReviewAndClose_updateStateAsExpected() { + val state = ConversationMediaPickerState( + isOpen = false, + captureMode = ConversationCaptureMode.Photo, + isReviewRequested = false, + reviewContentUri = null, + reviewRequestSequence = 0, + selectedMediaIds = setOf(SELECTED_MEDIA_ID), + shouldRestoreKeyboard = false, + ) + + assertTrue(state.isSelected(SELECTED_MEDIA_ID)) + + state.open() + assertTrue(state.isOpen) + assertTrue(state.isReviewRequested) + + state.showReview(REMOTE_CONTENT_URI) + assertEquals(REMOTE_CONTENT_URI, state.reviewContentUri) + assertEquals(1, state.reviewRequestSequence) + + state.clearReview() + assertFalse(state.isReviewRequested) + assertEquals(null, state.reviewContentUri) + + state.close() + assertFalse(state.isOpen) + assertFalse(state.isReviewRequested) + assertEquals(null, state.reviewContentUri) + assertFalse(state.isSelected(SELECTED_MEDIA_ID)) + } + + @Test + fun saver_roundTripsAllStateFields() { + val state = ConversationMediaPickerState( + isOpen = true, + captureMode = ConversationCaptureMode.Video, + isReviewRequested = true, + reviewContentUri = REMOTE_CONTENT_URI, + reviewRequestSequence = 7, + selectedMediaIds = setOf(SELECTED_MEDIA_ID, SECOND_SELECTED_MEDIA_ID), + shouldRestoreKeyboard = true, + ) + val saverScope = SaverScope { true } + + val savedState = with(ConversationMediaPickerState.Saver) { + with(saverScope) { + save(state) + } + } + + assertNotNull(savedState) + + val restoredState = with(ConversationMediaPickerState.Saver) { + restore(savedState!!) + } + + assertNotNull(restoredState) + assertEquals(ConversationCaptureMode.Video, restoredState!!.captureMode) + assertTrue(restoredState.isOpen) + assertTrue(restoredState.isReviewRequested) + assertEquals(REMOTE_CONTENT_URI, restoredState.reviewContentUri) + assertEquals(7, restoredState.reviewRequestSequence) + assertTrue(restoredState.isSelected(SELECTED_MEDIA_ID)) + assertTrue(restoredState.isSelected(SECOND_SELECTED_MEDIA_ID)) + assertTrue(restoredState.shouldRestoreKeyboard) + } + + private companion object { + private const val REMOTE_CONTENT_URI = "content://media/external/images/media/123" + private const val SELECTED_MEDIA_ID = "selected-media-1" + private const val SECOND_SELECTED_MEDIA_ID = "selected-media-2" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActionsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActionsTest.kt new file mode 100644 index 000000000..1cec9f7fb --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActionsTest.kt @@ -0,0 +1,169 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import androidx.camera.core.ImageCapture +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.util.ContentType +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ConversationMediaPickerActionsTest { + + @Test + fun photoFlashMode_cyclesThroughSupportedModes() { + assertEquals(ConversationPhotoFlashMode.Auto, ConversationPhotoFlashMode.Off.next()) + assertEquals(ConversationPhotoFlashMode.On, ConversationPhotoFlashMode.Auto.next()) + assertEquals(ConversationPhotoFlashMode.Off, ConversationPhotoFlashMode.On.next()) + } + + @Test + fun photoFlashMode_exposesImageCaptureFlashModes() { + assertEquals( + ImageCapture.FLASH_MODE_OFF, + ConversationPhotoFlashMode.Off.imageCaptureFlashMode, + ) + assertEquals( + ImageCapture.FLASH_MODE_AUTO, + ConversationPhotoFlashMode.Auto.imageCaptureFlashMode, + ) + assertEquals( + ImageCapture.FLASH_MODE_ON, + ConversationPhotoFlashMode.On.imageCaptureFlashMode, + ) + } + + @Test + fun photoCaptureRequest_doesNotCaptureWhenAttachmentStartIsRejected() { + val cameraController = mockk(relaxed = true) + var readyCount = 0 + var reviewCount = 0 + + handlePhotoCaptureRequest( + cameraController = cameraController, + onAttachmentStartRequest = { false }, + onCapturedMediaReady = { readyCount++ }, + onShowReview = { reviewCount++ }, + ) + + verify(exactly = 0) { + cameraController.capturePhoto( + onCaptured = any(), + onError = any(), + ) + } + assertEquals(0, readyCount) + assertEquals(0, reviewCount) + } + + @Test + fun photoCaptureRequest_forwardsCapturedMediaAndShowsReview() { + val cameraController = mockk() + val capturedMedia = ConversationCapturedMedia( + contentUri = CONTENT_URI, + contentType = ContentType.IMAGE_JPEG, + ) + var readyMedia: ConversationCapturedMedia? = null + var reviewedContentUri: String? = null + every { + cameraController.capturePhoto( + onCaptured = any(), + onError = any(), + ) + } answers { + firstArg<(ConversationCapturedMedia) -> Unit>().invoke(capturedMedia) + } + + handlePhotoCaptureRequest( + cameraController = cameraController, + onAttachmentStartRequest = { true }, + onCapturedMediaReady = { media -> readyMedia = media }, + onShowReview = { contentUri -> reviewedContentUri = contentUri }, + ) + + assertEquals(capturedMedia, readyMedia) + assertEquals(CONTENT_URI, reviewedContentUri) + } + + @Test + fun videoCaptureRequest_stopsActiveRecordingWithoutStartingAttachment() { + val cameraController = mockk(relaxed = true) + var attachmentStartCount = 0 + + handleVideoCaptureRequest( + cameraController = cameraController, + isRecording = true, + onAttachmentStartRequest = { + attachmentStartCount++ + true + }, + onCapturedMediaReady = {}, + onShowReview = {}, + ) + + verify(exactly = 1) { + cameraController.stopVideoRecording() + } + verify(exactly = 0) { + cameraController.startVideoRecording( + withAudio = any(), + onCaptured = any(), + onDiscarded = any(), + onError = any(), + ) + } + assertEquals(0, attachmentStartCount) + } + + @Test + fun videoCaptureRequest_startsRecordingAndForwardsCapturedMedia() { + val cameraController = mockk() + val capturedMedia = ConversationCapturedMedia( + contentUri = CONTENT_URI, + contentType = ContentType.VIDEO_MP4, + ) + var readyMedia: ConversationCapturedMedia? = null + var reviewedContentUri: String? = null + every { + cameraController.startVideoRecording( + withAudio = true, + onCaptured = any(), + onDiscarded = any(), + onError = any(), + ) + } answers { + secondArg<(ConversationCapturedMedia) -> Unit>().invoke(capturedMedia) + } + + handleVideoCaptureRequest( + cameraController = cameraController, + isRecording = false, + onAttachmentStartRequest = { true }, + onCapturedMediaReady = { media -> readyMedia = media }, + onShowReview = { contentUri -> reviewedContentUri = contentUri }, + ) + + assertEquals(capturedMedia, readyMedia) + assertEquals(CONTENT_URI, reviewedContentUri) + } + + @Test + fun cameraUtilityRequests_delegateToController() { + val cameraController = mockk(relaxed = true) + + handleSwitchCameraRequest(cameraController = cameraController) + handleToggleFlashRequest(cameraController = cameraController) + + verify(exactly = 1) { + cameraController.switchCamera(onError = any()) + } + verify(exactly = 1) { + cameraController.cyclePhotoFlashMode(onError = any()) + } + } + + private companion object { + private const val CONTENT_URI = "content://media/captured/1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCacheTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCacheTest.kt new file mode 100644 index 000000000..ccaffdd40 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCacheTest.kt @@ -0,0 +1,48 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import android.graphics.Bitmap +import io.mockk.mockk +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +internal class ConversationMediaReviewBitmapCacheTest { + + @Test + fun putAndGet_returnsBitmapByContentUri() { + val cache = ConversationMediaReviewBitmapCache() + val bitmap = mockk() + + cache.put( + contentUri = CONTENT_URI, + bitmap = bitmap, + ) + + assertSame(bitmap, cache[CONTENT_URI]) + } + + @Test + fun removeInactive_removesOnlyInactiveContentUris() { + val cache = ConversationMediaReviewBitmapCache() + val activeBitmap = mockk() + val inactiveBitmap = mockk() + cache.put( + contentUri = CONTENT_URI, + bitmap = activeBitmap, + ) + cache.put( + contentUri = INACTIVE_CONTENT_URI, + bitmap = inactiveBitmap, + ) + + cache.removeInactive(activeContentUris = setOf(CONTENT_URI)) + + assertSame(activeBitmap, cache[CONTENT_URI]) + assertNull(cache[INACTIVE_CONTENT_URI]) + } + + private companion object { + private const val CONTENT_URI = "content://media/image/1" + private const val INACTIVE_CONTENT_URI = "content://media/image/2" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/BaseConversationMediaPickerDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/BaseConversationMediaPickerDelegateTest.kt new file mode 100644 index 000000000..a3b5de16b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/BaseConversationMediaPickerDelegateTest.kt @@ -0,0 +1,74 @@ +package com.android.messaging.ui.conversation.mediapicker.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationMediaPickerDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected fun createDelegate( + draftDelegate: ConversationDraftDelegate, + attachmentMapper: ConversationDraftAttachmentMapper, + attachmentRepository: ConversationAttachmentsRepository, + defaultDispatcher: CoroutineDispatcher, + ): ConversationMediaPickerDelegateImpl { + return ConversationMediaPickerDelegateImpl( + conversationDraftDelegate = draftDelegate, + conversationAttachmentsRepository = attachmentRepository, + conversationDraftAttachmentMapper = attachmentMapper, + defaultDispatcher = defaultDispatcher, + ) + } + + protected fun createMediaPickerDraftDelegateMock(): ConversationDraftDelegate { + val stateFlow = MutableStateFlow(ConversationDraftState()) + val draftDelegate = mockk(relaxed = true) + every { draftDelegate.state } returns stateFlow + every { draftDelegate.tryStartAddingAttachment() } returns true + every { + draftDelegate.addAttachments(attachments = any()) + } answers { + firstArg>() + } + return draftDelegate + } + + protected fun createDraftAttachmentMapperMock(): ConversationDraftAttachmentMapper { + return mockk(relaxed = true) + } + + protected fun createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow: + Flow = flowOf(), + createDraftAttachmentFromContactFlow: Flow = flowOf(null), + deleteTemporaryAttachmentFlow: Flow = flowOf(Unit), + ): ConversationAttachmentsRepository { + val attachmentRepository = mockk() + every { + attachmentRepository.createDraftAttachmentsFromPhotoPicker(any()) + } returns createDraftAttachmentsFromPhotoPickerFlow + every { + attachmentRepository.createDraftAttachmentFromContact(any()) + } returns createDraftAttachmentFromContactFlow + every { + attachmentRepository.deleteTemporaryAttachment(any()) + } returns deleteTemporaryAttachmentFlow + return attachmentRepository + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateDirectSourcesTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateDirectSourcesTest.kt new file mode 100644 index 000000000..830ad0d6d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateDirectSourcesTest.kt @@ -0,0 +1,145 @@ +package com.android.messaging.ui.conversation.mediapicker.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.testutil.TEST_CONTACT_URI +import io.mockk.every +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerDelegateDirectSourcesTest : + BaseConversationMediaPickerDelegateTest() { + + @Test + fun onCapturedMediaReady_addsSingleDraftAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachmentMapper = createDraftAttachmentMapperMock() + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = attachmentMapper, + attachmentRepository = createAttachmentRepositoryMock(), + defaultDispatcher = mainDispatcherRule.testDispatcher, + ) + val capturedMedia = ConversationCapturedMedia( + contentUri = "content://scratch/1", + contentType = "image/jpeg", + width = 800, + height = 600, + ) + val attachment = ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://scratch/1", + width = 800, + height = 600, + ) + + every { + attachmentMapper.map(capturedMedia = capturedMedia) + } returns attachment + + delegate.onCapturedMediaReady(capturedMedia = capturedMedia) + + verify(exactly = 1) { + attachmentMapper.map(capturedMedia = capturedMedia) + } + verify(exactly = 1) { + draftDelegate.addAttachments(attachments = listOf(attachment)) + } + } + } + + @Test + fun onContactCardPicked_addsResolvedContactAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachment = ConversationDraftAttachment( + contentType = "text/x-vCard", + contentUri = "content://contacts/as_vcard/1", + ) + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentFromContactFlow = flowOf(attachment), + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onContactCardPicked(contactUri = TEST_CONTACT_URI) + advanceUntilIdle() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.createDraftAttachmentFromContact( + contactUri = TEST_CONTACT_URI, + ) + } + verify(exactly = 1) { + draftDelegate.addAttachments(attachments = listOf(attachment)) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onContactCardPicked_ignoresBlankUris() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentRepository = createAttachmentRepositoryMock() + val delegate = createDelegate( + draftDelegate = createMediaPickerDraftDelegateMock(), + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onContactCardPicked(contactUri = " ") + advanceUntilIdle() + + verify(exactly = 0) { + @Suppress("UnusedFlow") + attachmentRepository.createDraftAttachmentFromContact(any()) + } + } finally { + boundScope.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegatePhotoPickerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegatePhotoPickerTest.kt new file mode 100644 index 000000000..3e9b2be74 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegatePhotoPickerTest.kt @@ -0,0 +1,431 @@ +package com.android.messaging.ui.conversation.mediapicker.delegate + +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerDelegatePhotoPickerTest : + BaseConversationMediaPickerDelegateTest() { + + @Test + fun onPhotoPickerMediaSelected_addsResolvedDraftAttachments() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachments = listOf( + ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://picker/1", + width = 640, + height = 480, + ), + ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = "content://picker/2", + width = 1280, + height = 720, + ), + ) + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flowOf( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = + PhotoPickerDraftAttachment( + sourceContentUri = "content://picker/source/1", + draftAttachment = attachments[0], + ), + ), + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = + PhotoPickerDraftAttachment( + sourceContentUri = "content://picker/source/2", + draftAttachment = attachments[1], + ), + ), + ), + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected( + contentUris = listOf( + "content://picker/source/1", + "content://picker/source/2", + ), + ) + advanceUntilIdle() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf( + "content://picker/source/1", + "content://picker/source/2", + ), + ) + } + verify(exactly = 1) { + draftDelegate.addAttachments(attachments = listOf(attachments[0])) + } + verify(exactly = 1) { + draftDelegate.addAttachments(attachments = listOf(attachments[1])) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onPhotoPickerMediaSelected_ignoresEmptyLists() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentRepository = createAttachmentRepositoryMock() + val draftDelegate = createMediaPickerDraftDelegateMock() + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected(contentUris = emptyList()) + advanceUntilIdle() + + verify(exactly = 0) { + @Suppress("UnusedFlow") + attachmentRepository.createDraftAttachmentsFromPhotoPicker(any()) + } + verify(exactly = 0) { + draftDelegate.addAttachments(any()) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onPhotoPickerMediaSelected_ignoresAlreadySelectedUris() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flowOf( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = + PhotoPickerDraftAttachment( + sourceContentUri = "content://picker/1", + draftAttachment = ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://scratch/1", + ), + ), + ), + ), + ) + val draftDelegate = createMediaPickerDraftDelegateMock() + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + advanceUntilIdle() + delegate.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + advanceUntilIdle() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf("content://picker/1"), + ) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onPhotoPickerMediaDeselected_removesDraftAttachmentsAndDeletesTemporaryAttachment() { + runTest(context = mainDispatcherRule.testDispatcher) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flowOf( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = + PhotoPickerDraftAttachment( + sourceContentUri = "content://picker/1", + draftAttachment = ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://scratch/1", + ), + ), + ), + ), + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + advanceUntilIdle() + delegate.onPhotoPickerMediaDeselected( + contentUris = listOf( + "content://picker/1", + " ", + ), + ) + advanceUntilIdle() + + verify(exactly = 1) { + draftDelegate.removeAttachment(contentUri = "content://scratch/1") + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.deleteTemporaryAttachment( + contentUri = "content://scratch/1", + ) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onPhotoPickerMediaDeselected_beforeResolutionDoesNotAddResolvedAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val resolutionStarted = CompletableDeferred() + val releaseResolution = CompletableDeferred() + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flow { + resolutionStarted.complete(Unit) + releaseResolution.await() + emit( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = + PhotoPickerDraftAttachment( + sourceContentUri = "content://picker/1", + draftAttachment = ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://scratch/1", + ), + ), + ), + ) + }, + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + runCurrent() + resolutionStarted.await() + + delegate.onPhotoPickerMediaDeselected(contentUris = listOf("content://picker/1")) + releaseResolution.complete(Unit) + advanceUntilIdle() + + verify(exactly = 0) { + draftDelegate.addAttachments(any()) + } + verify(exactly = 1) { + draftDelegate.removeAttachment(contentUri = "content://picker/1") + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.deleteTemporaryAttachment( + contentUri = "content://picker/1", + ) + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.deleteTemporaryAttachment( + contentUri = "content://scratch/1", + ) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onPhotoPickerMediaSelected_whenResolutionFailsEmitsMessageEffect() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flowOf( + PhotoPickerDraftAttachmentResult.Failed( + sourceContentUri = "content://picker/1", + ), + ), + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.effects.test { + delegate.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.fail_to_load_attachment, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 0) { + draftDelegate.addAttachments(any()) + } + } finally { + boundScope.cancel() + } + } + } + + @Test + fun photoPickerSourceContentUriByAttachmentContentUri_returnsPickerUriForResolvedAttachment() { + runTest(context = mainDispatcherRule.testDispatcher) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flowOf( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = + PhotoPickerDraftAttachment( + sourceContentUri = "content://picker/1", + draftAttachment = ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = "content://scratch/1", + ), + ), + ), + ), + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + advanceUntilIdle() + + assertEquals( + "content://picker/1", + delegate.photoPickerSourceContentUriByAttachmentContentUri.value[ + "content://scratch/1", + ], + ) + } finally { + boundScope.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateRemovalTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateRemovalTest.kt new file mode 100644 index 000000000..b28a5568e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegateRemovalTest.kt @@ -0,0 +1,118 @@ +package com.android.messaging.ui.conversation.mediapicker.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerDelegateRemovalTest : + BaseConversationMediaPickerDelegateTest() { + + @Test + fun onRemoveResolvedAttachment_removesDraftAndDeletesTemporaryAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val dispatcher = mainDispatcherRule.testDispatcher + val draftDelegate = createMediaPickerDraftDelegateMock() + val attachment = ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = RESOLVED_CONTENT_URI, + ) + val attachmentRepository = createAttachmentRepositoryMock( + createDraftAttachmentsFromPhotoPickerFlow = flowOf( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = PhotoPickerDraftAttachment( + sourceContentUri = REMOTE_CONTENT_URI, + draftAttachment = attachment, + ), + ), + ), + deleteTemporaryAttachmentFlow = flowOf(Unit), + ) + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = attachmentRepository, + defaultDispatcher = dispatcher, + ) + val boundScope = CoroutineScope(dispatcher + SupervisorJob()) + + try { + delegate.bind( + scope = boundScope, + conversationIdFlow = MutableStateFlow(value = null), + ) + + delegate.onPhotoPickerMediaSelected(contentUris = listOf(REMOTE_CONTENT_URI)) + advanceUntilIdle() + assertEquals( + REMOTE_CONTENT_URI, + delegate.photoPickerSourceContentUriByAttachmentContentUri.value[ + RESOLVED_CONTENT_URI + ], + ) + + delegate.onRemoveResolvedAttachment(contentUri = RESOLVED_CONTENT_URI) + advanceUntilIdle() + + verify(exactly = 1) { + draftDelegate.removeAttachment(contentUri = RESOLVED_CONTENT_URI) + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + attachmentRepository.deleteTemporaryAttachment( + contentUri = RESOLVED_CONTENT_URI, + ) + } + assertEquals( + emptyMap(), + delegate.photoPickerSourceContentUriByAttachmentContentUri.value, + ) + } finally { + boundScope.cancel() + } + } + } + + @Test + fun onRemovePendingAttachment_removesPendingDraftAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher + ) { + val draftDelegate = createMediaPickerDraftDelegateMock() + val delegate = createDelegate( + draftDelegate = draftDelegate, + attachmentMapper = createDraftAttachmentMapperMock(), + attachmentRepository = createAttachmentRepositoryMock(), + defaultDispatcher = mainDispatcherRule.testDispatcher, + ) + + delegate.onRemovePendingAttachment(pendingAttachmentId = PENDING_ATTACHMENT_ID) + + verify(exactly = 1) { + draftDelegate.removePendingAttachment(pendingAttachmentId = PENDING_ATTACHMENT_ID) + } + } + } + + private companion object { + private const val PENDING_ATTACHMENT_ID = "pending-1" + private const val REMOTE_CONTENT_URI = "content://remote/1" + private const val RESOLVED_CONTENT_URI = "content://scratch/1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapperImplTest.kt new file mode 100644 index 000000000..65daf0aa0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapperImplTest.kt @@ -0,0 +1,35 @@ +package com.android.messaging.ui.conversation.mediapicker.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.ConversationCapturedMedia +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationDraftAttachmentMapperImplTest { + + private val mapper = ConversationDraftAttachmentMapperImpl() + + @Test + fun map_capturedMedia_returnsDraftAttachment() { + val capturedMedia = ConversationCapturedMedia( + contentUri = "content://scratch/1", + contentType = "video/mp4", + width = 1920, + height = 1080, + ) + + val attachment = mapper.map( + capturedMedia = capturedMedia, + ) + + assertEquals( + ConversationDraftAttachment( + contentType = "video/mp4", + contentUri = "content://scratch/1", + width = 1920, + height = 1080, + ), + attachment, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/repository/ConversationAttachmentsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/repository/ConversationAttachmentsRepositoryImplTest.kt new file mode 100644 index 000000000..8d410a3ab --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/repository/ConversationAttachmentsRepositoryImplTest.kt @@ -0,0 +1,677 @@ +package com.android.messaging.ui.conversation.mediapicker.repository + +import android.content.ContentResolver +import android.content.ContentValues +import android.database.MatrixCursor +import android.net.Uri +import android.os.Environment +import android.provider.ContactsContract.Contacts +import android.provider.MediaStore +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.model.SaveAttachmentsResult +import com.android.messaging.data.media.repository.ConversationAttachmentsRepositoryImpl +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONTACT_URI +import com.android.messaging.util.ContentType +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ConversationAttachmentsRepositoryImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun createDraftAttachmentsFromPhotoPicker_resolvesImageAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val imageUri = Uri.parse("content://picker/image") + val scratchUri = + Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/scratch-image") + val scratchBytes = ByteArrayOutputStream() + val contentResolver = createContentResolverForPhotoPicker( + uri = imageUri, + contentType = "image/png", + bytes = onePixelPngBytes, + scratchUri = scratchUri, + scratchSink = scratchBytes, + ) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + mockkStatic(MediaScratchFileProvider::class) + try { + every { + MediaScratchFileProvider.buildMediaScratchSpaceUri("png") + } returns scratchUri + + repository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf(imageUri.toString()), + ).test { + assertEquals( + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = PhotoPickerDraftAttachment( + sourceContentUri = imageUri.toString(), + draftAttachment = ConversationDraftAttachment( + contentType = "image/png", + contentUri = scratchUri.toString(), + width = 1, + height = 1, + ), + ), + ), + awaitItem(), + ) + awaitComplete() + } + + assertArrayEquals(onePixelPngBytes, scratchBytes.toByteArray()) + verify(exactly = 1) { + contentResolver.openOutputStream(scratchUri) + } + } finally { + unmockkStatic(MediaScratchFileProvider::class) + } + } + } + + @Test + fun createDraftAttachmentsFromPhotoPicker_dropsUnsupportedAttachment() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val contentResolver = createContentResolverForPhotoPicker( + uri = Uri.parse("content://picker/file"), + contentType = "application/pdf", + bytes = ByteArray(0), + ) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.createDraftAttachmentsFromPhotoPicker( + contentUris = listOf("content://picker/file"), + ).test { + assertEquals( + PhotoPickerDraftAttachmentResult.Failed( + sourceContentUri = "content://picker/file", + ), + awaitItem(), + ) + awaitComplete() + } + } + } + + @Test + fun createDraftAttachmentFromContact_returnsVCardAttachmentForResolvedLookupKey() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = createContentResolver( + contactCursor = createContactsCursor( + arrayOf("lookup-key-1"), + ), + ), + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.createDraftAttachmentFromContact(contactUri = TEST_CONTACT_URI).test { + assertEquals( + ConversationDraftAttachment( + contentType = ContentType.TEXT_VCARD, + contentUri = Uri.withAppendedPath( + Contacts.CONTENT_VCARD_URI, + "lookup-key-1", + ).toString(), + ), + awaitItem(), + ) + awaitComplete() + } + } + } + + @Test + fun createDraftAttachmentFromContact_returnsNullWhenLookupKeyIsMissing() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = createContentResolver( + contactCursor = createContactsCursor( + arrayOf(""), + ), + ), + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.createDraftAttachmentFromContact(contactUri = TEST_CONTACT_URI).test { + assertEquals(null, awaitItem()) + awaitComplete() + } + } + } + + @Test + fun createDraftAttachmentFromContact_swallowsNonCancellationFailures() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val contentResolver = mockk() + every { + contentResolver.query(any(), any(), any(), any(), any()) + } throws IllegalStateException("boom") + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.createDraftAttachmentFromContact(contactUri = TEST_CONTACT_URI).test { + assertEquals(null, awaitItem()) + awaitComplete() + } + } + } + + @Test + fun deleteTemporaryAttachment_deletesScratchUrisAndNoOpsElsewhere() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val contentResolver = createContentResolver(contactCursor = createContactsCursor()) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + val scratchUri = Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/12345") + val nonScratchUri = Uri.parse("content://example.com/12345") + + repository.deleteTemporaryAttachment(contentUri = scratchUri.toString()).test { + assertEquals(Unit, awaitItem()) + awaitComplete() + } + + verify(exactly = 1) { + contentResolver.delete(scratchUri, null, null) + } + + repository.deleteTemporaryAttachment(contentUri = nonScratchUri.toString()).test { + assertEquals(Unit, awaitItem()) + awaitComplete() + } + + verify(exactly = 0) { + contentResolver.delete(nonScratchUri, null, null) + } + } + } + + @Test + fun deleteTemporaryAttachment_swallowsNonCancellationFailures() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val contentResolver = createContentResolver(contactCursor = createContactsCursor()) + val scratchUri = Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/12345") + every { + contentResolver.delete(scratchUri, null, null) + } throws IllegalStateException("boom") + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.deleteTemporaryAttachment(contentUri = scratchUri.toString()).test { + assertEquals(Unit, awaitItem()) + awaitComplete() + } + + verify(exactly = 1) { + contentResolver.delete(scratchUri, null, null) + } + } + } + + @Test + fun saveAttachmentsToMediaStore_savesImageToPicturesAndFinalizesPendingRow() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val pendingUri = Uri.parse("content://media/external/images/media/pending") + val sink = ByteArrayOutputStream() + val contentResolver = createContentResolverForSave( + pendingUri = pendingUri, + sourceBytes = byteArrayOf(1, 2, 3), + sink = sink, + ) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "image/jpeg", + contentUri = "content://source/image.jpg", + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 1, + videoCount = 0, + otherCount = 0, + failCount = 0, + ), + awaitItem(), + ) + awaitComplete() + } + + val insertValues = slot() + verify(exactly = 1) { + contentResolver.insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + capture(insertValues), + ) + } + assertEquals( + "${Environment.DIRECTORY_PICTURES}/Messaging", + insertValues.captured.getAsString(MediaStore.MediaColumns.RELATIVE_PATH), + ) + assertEquals( + "image/jpeg", + insertValues.captured.getAsString(MediaStore.MediaColumns.MIME_TYPE), + ) + assertEquals( + 1, + insertValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING), + ) + + assertArrayEquals(byteArrayOf(1, 2, 3), sink.toByteArray()) + + val finalizeValues = slot() + verify(exactly = 1) { + contentResolver.update(pendingUri, capture(finalizeValues), null, null) + } + assertEquals( + 0, + finalizeValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING), + ) + verify(exactly = 0) { + contentResolver.delete(pendingUri, null, null) + } + } + } + + @Test + fun saveAttachmentsToMediaStore_savesVideoToPicturesAndCountsAsVideo() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val pendingUri = Uri.parse("content://media/external/video/media/pending") + val contentResolver = createContentResolverForSave(pendingUri = pendingUri) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "video/mp4", + contentUri = "content://source/video.mp4", + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 0, + videoCount = 1, + otherCount = 0, + failCount = 0, + ), + awaitItem(), + ) + awaitComplete() + } + + val insertValues = slot() + verify(exactly = 1) { + contentResolver.insert( + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + capture(insertValues), + ) + } + assertEquals( + "${Environment.DIRECTORY_PICTURES}/Messaging", + insertValues.captured.getAsString(MediaStore.MediaColumns.RELATIVE_PATH), + ) + } + } + + @Test + fun saveAttachmentsToMediaStore_savesAudioToMusicAndCountsAsOther() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val pendingUri = Uri.parse("content://media/external/audio/media/pending") + val contentResolver = createContentResolverForSave(pendingUri = pendingUri) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "audio/mpeg", + contentUri = "content://source/audio.mp3", + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 0, + videoCount = 0, + otherCount = 1, + failCount = 0, + ), + awaitItem(), + ) + awaitComplete() + } + + val insertValues = slot() + verify(exactly = 1) { + contentResolver.insert( + MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + capture(insertValues), + ) + } + assertEquals( + "${Environment.DIRECTORY_MUSIC}/Messaging", + insertValues.captured.getAsString(MediaStore.MediaColumns.RELATIVE_PATH), + ) + } + } + + @Test + fun saveAttachmentsToMediaStore_countsFailureWhenInsertReturnsNull() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val contentResolver = mockk() + every { contentResolver.insert(any(), any()) } returns null + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "image/jpeg", + contentUri = "content://source/image.jpg", + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 0, + videoCount = 0, + otherCount = 0, + failCount = 1, + ), + awaitItem(), + ) + awaitComplete() + } + + verify(exactly = 0) { contentResolver.openInputStream(any()) } + verify(exactly = 0) { contentResolver.update(any(), any(), any(), any()) } + verify(exactly = 0) { contentResolver.delete(any(), any(), any()) } + } + } + + @Test + fun saveAttachmentsToMediaStore_deletesPendingRowAndCountsFailureWhenCopyThrows() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val pendingUri = Uri.parse("content://media/external/images/media/pending") + val contentResolver = mockk() + every { contentResolver.insert(any(), any()) } returns pendingUri + every { contentResolver.openInputStream(any()) } throws IOException("boom") + every { contentResolver.delete(pendingUri, null, null) } returns 1 + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "image/jpeg", + contentUri = "content://source/image.jpg", + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 0, + videoCount = 0, + otherCount = 0, + failCount = 1, + ), + awaitItem(), + ) + awaitComplete() + } + + verify(exactly = 1) { + contentResolver.delete(pendingUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(pendingUri, any(), any(), any()) + } + } + } + + @Test + fun saveAttachmentsToMediaStore_aggregatesCountsAcrossImageAndVideo() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + val pendingUri = Uri.parse("content://media/external/pending") + val contentResolver = createContentResolverForSave(pendingUri = pendingUri) + val repository = ConversationAttachmentsRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + repository.saveAttachmentsToMediaStore( + attachments = listOf( + AttachmentToSave( + contentType = "image/jpeg", + contentUri = "content://source/image.jpg", + ), + AttachmentToSave( + contentType = "video/mp4", + contentUri = "content://source/video.mp4", + ), + ), + ).test { + assertEquals( + SaveAttachmentsResult( + imageCount = 1, + videoCount = 1, + otherCount = 0, + failCount = 0, + ), + awaitItem(), + ) + awaitComplete() + } + } + } + + private fun createContentResolverForSave( + pendingUri: Uri, + sourceBytes: ByteArray = ByteArray(0), + sink: ByteArrayOutputStream = ByteArrayOutputStream(), + ): ContentResolver { + val contentResolver = mockk() + every { contentResolver.insert(any(), any()) } returns pendingUri + every { contentResolver.openInputStream(any()) } answers + { ByteArrayInputStream(sourceBytes) } + every { contentResolver.openOutputStream(pendingUri) } returns sink + every { contentResolver.update(any(), any(), any(), any()) } returns 1 + every { contentResolver.delete(any(), any(), any()) } returns 1 + + return contentResolver + } + + private fun createContentResolverForPhotoPicker( + uri: Uri, + contentType: String, + bytes: ByteArray, + scratchUri: Uri = Uri.parse("content://${MediaScratchFileProvider.AUTHORITY}/scratch"), + scratchSink: ByteArrayOutputStream = ByteArrayOutputStream(), + ): ContentResolver { + val contentResolver = mockk() + every { contentResolver.getType(uri) } returns contentType + every { contentResolver.openInputStream(uri) } returns ByteArrayInputStream(bytes) + every { contentResolver.openInputStream(scratchUri) } returns ByteArrayInputStream(bytes) + every { contentResolver.openOutputStream(scratchUri) } returns scratchSink + every { contentResolver.delete(scratchUri, null, null) } returns 1 + return contentResolver + } + + private fun createContentResolver( + contactCursor: MatrixCursor, + ): ContentResolver { + val contentResolver = mockk() + + every { + contentResolver.query(any(), any(), any(), any(), any()) + } returns contactCursor + + every { + contentResolver.delete(any(), any(), any()) + } returns 1 + + return contentResolver + } + + private fun createContactsCursor( + vararg rows: Array, + ): MatrixCursor { + val cursor = MatrixCursor(arrayOf(Contacts.LOOKUP_KEY)) + rows.forEach { row -> + cursor.addRow(row) + } + return cursor + } + + private companion object { + private val onePixelPngBytes = byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4.toByte(), + 0x89.toByte(), + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C.toByte(), + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4.toByte(), + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE.toByte(), + 0x42, + 0x60, + 0x82.toByte(), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt new file mode 100644 index 000000000..3a26093de --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt @@ -0,0 +1,295 @@ +package com.android.messaging.ui.conversation.metadata.delegate + +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationMetadataDelegateImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun onArchiveConversationClick_archivesViaRepositoryAndEmitsCloseConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + harness.delegate.effects.test { + harness.delegate.onArchiveConversationClick() + advanceUntilIdle() + + assertEquals(ConversationScreenEffect.CloseConversation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + harness.conversationsRepository + .archiveConversation(conversationId = "conversation-42") + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onUnarchiveConversationClick_unarchivesViaRepositoryWithoutClosing() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + harness.delegate.effects.test { + harness.delegate.onUnarchiveConversationClick() + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + harness.conversationsRepository + .unarchiveConversation(conversationId = "conversation-42") + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onAddContactClick_emitsLaunchAddContactFlowWithDestination() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + harness.setPresentState( + otherParticipantPhoneNumber = TEST_CALL_ACTION_PHONE_NUMBER, + ) + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onAddContactClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.LaunchAddContactFlow( + destination = TEST_CALL_ACTION_PHONE_NUMBER, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onAddContactClick_doesNothingWhenDestinationMissing() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + harness.setPresentState(otherParticipantPhoneNumber = null) + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onAddContactClick() + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onDeleteConversationClick_togglesConfirmationVisibility() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + advanceUntilIdle() + assertEquals( + false, + harness.delegate.isDeleteConversationConfirmationVisible.value, + ) + + harness.delegate.onDeleteConversationClick() + advanceUntilIdle() + assertEquals( + true, + harness.delegate.isDeleteConversationConfirmationVisible.value, + ) + + harness.delegate.dismissDeleteConversationConfirmation() + advanceUntilIdle() + assertEquals( + false, + harness.delegate.isDeleteConversationConfirmationVisible.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun confirmDeleteConversation_deletesViaRepositoryAndEmitsCloseConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + advanceUntilIdle() + harness.delegate.onDeleteConversationClick() + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.confirmDeleteConversation() + advanceUntilIdle() + + assertEquals(ConversationScreenEffect.CloseConversation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + false, + harness.delegate.isDeleteConversationConfirmationVisible.value, + ) + verify(exactly = 1) { + harness.conversationsRepository + .deleteConversation( + conversationId = "conversation-42", + cutoffTimestamp = any(), + ) + } + } finally { + harness.cancel() + } + } + } + + @Test + fun commandMethods_doNothingWhenConversationIdIsBlankOrNull() { + runTest(context = mainDispatcherRule.testDispatcher) { + listOf(null, " ").forEach { blankOrNullConversationId -> + val harness = createHarness(conversationId = blankOrNullConversationId) + + try { + harness.delegate.effects.test { + harness.delegate.onArchiveConversationClick() + harness.delegate.onUnarchiveConversationClick() + harness.delegate.onDeleteConversationClick() + harness.delegate.confirmDeleteConversation() + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertEquals( + false, + harness.delegate.isDeleteConversationConfirmationVisible.value, + ) + } finally { + harness.cancel() + } + } + } + } + + private fun createHarness(conversationId: String?): DelegateHarness { + val dispatcher = mainDispatcherRule.testDispatcher + val scope = TestScope(dispatcher) + val conversationsRepository = mockk(relaxed = true) + val mapper = mockk() + val conversationIdFlow = MutableStateFlow(conversationId) + val metadataFlow = MutableStateFlow(value = null) + + every { + conversationsRepository.getConversationMetadata(any()) + } returns metadataFlow + every { + mapper.map(metadata = any()) + } answers { + val metadata = firstArg() + ConversationMetadataUiState.Present( + title = "Carol", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = metadata.otherParticipantPhotoUri, + ), + participantCount = 2, + otherParticipantDisplayDestination = metadata.otherParticipantDisplayDestination, + otherParticipantPhoneNumber = metadata.otherParticipantNormalizedDestination, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + } + + val delegate = ConversationMetadataDelegateImpl( + checkConversationActionRequirements = { + ConversationActionRequirementsResult.Ready + }, + conversationsRepository = conversationsRepository, + conversationMetadataUiStateMapper = mapper, + defaultDispatcher = dispatcher, + ) + delegate.bind( + scope = scope, + conversationIdFlow = conversationIdFlow, + ) + + return DelegateHarness( + delegate = delegate, + conversationsRepository = conversationsRepository, + metadataFlow = metadataFlow, + scope = scope, + ) + } + + private fun DelegateHarness.setPresentState( + otherParticipantPhoneNumber: String?, + ) { + metadataFlow.value = mockk(relaxed = true) { + every { otherParticipantNormalizedDestination } returns otherParticipantPhoneNumber + } + } + + private data class DelegateHarness( + val delegate: ConversationMetadataDelegateImpl, + val conversationsRepository: ConversationsRepository, + val metadataFlow: MutableStateFlow, + val scope: TestScope, + ) { + fun cancel() { + scope.cancel() + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt new file mode 100644 index 000000000..5f9384d16 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt @@ -0,0 +1,79 @@ +package com.android.messaging.ui.conversation.metadata.mapper + +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationMetadataUiStateMapperImplTest { + + private val mapper = ConversationMetadataUiStateMapperImpl() + + @Test + fun map_oneOnOneConversation_usesSingleAvatarAndDisplayDestination() { + val result = mapper.map( + metadata = ConversationMetadata( + conversationName = "Carol", + selfParticipantId = "self-1", + isGroupConversation = false, + includeEmailAddress = false, + participantCount = 1, + otherParticipantDisplayDestination = "(555) 123-4567", + otherParticipantNormalizedDestination = TEST_CALL_ACTION_PHONE_NUMBER, + otherParticipantContactLookupKey = "lookup-key", + otherParticipantPhotoUri = "content://contacts/people/1/photo", + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + sortTimestamp = 0L, + ), + ) + + assertEquals( + ConversationMetadataUiState.Present( + title = "Carol", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = "content://contacts/people/1/photo", + ), + participantCount = 1, + otherParticipantDisplayDestination = "(555) 123-4567", + otherParticipantPhoneNumber = TEST_CALL_ACTION_PHONE_NUMBER, + otherParticipantContactLookupKey = "lookup-key", + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ), + result, + ) + } + + @Test + fun map_groupConversation_usesGroupAvatarAndNoPhoneNumber() { + val result = mapper.map( + metadata = ConversationMetadata( + conversationName = "Weekend plan", + selfParticipantId = "self-1", + isGroupConversation = true, + includeEmailAddress = false, + participantCount = 3, + otherParticipantDisplayDestination = null, + otherParticipantNormalizedDestination = "not-a-phone-number", + otherParticipantContactLookupKey = null, + otherParticipantPhotoUri = "content://contacts/people/1/photo", + isArchived = true, + composerAvailability = ConversationComposerAvailability.Editable, + sortTimestamp = 0L, + ), + ) + + val presentState = result as ConversationMetadataUiState.Present + assertEquals(ConversationMetadataUiState.Avatar.Group, presentState.avatar) + assertNull(presentState.otherParticipantPhoneNumber) + assertEquals(true, presentState.isArchived) + } +} From 6598345e341d5ebc220e1c70733d515ae881773e Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:38:36 +0300 Subject: [PATCH 09/38] Add conversation messages unit tests --- .../BaseConversationMessagesDelegateTest.kt | 198 ++++++++ ...ConversationMessagesDelegateBindingTest.kt | 208 +++++++++ ...onversationMessagesDelegateMessagesTest.kt | 108 +++++ .../ConversationMessagesDelegateVCardTest.kt | 428 ++++++++++++++++++ ...onversationMessageSelectionDelegateTest.kt | 174 +++++++ ...ationMessageSelectionDelegateDeleteTest.kt | 98 ++++ ...sageSelectionDelegateMessageActionsTest.kt | 206 +++++++++ ...ationMessageSelectionDelegateResendTest.kt | 283 ++++++++++++ ...sageSelectionDelegateSaveAttachmentTest.kt | 203 +++++++++ ...onMessageSelectionDelegateSelectionTest.kt | 150 ++++++ ...sationMessageSelectionDelegateShareTest.kt | 103 +++++ ...ionVCardAttachmentUiModelMapperImplTest.kt | 98 ++++ ...aseConversationMessageUiModelMapperTest.kt | 106 +++++ ...ersationMessageUiModelMapperMappingTest.kt | 116 +++++ ...tionMessageUiModelMapperMmsDownloadTest.kt | 93 ++++ ...nversationMessageUiModelMapperPartsTest.kt | 407 +++++++++++++++++ ...geUiModelMapperProtocolAndTimestampTest.kt | 93 ++++ ...versationMessageUiModelMapperStatusTest.kt | 56 +++ ...versationAttachmentActionDispatcherTest.kt | 110 +++++ ...nversationAttachmentSectionsBuilderTest.kt | 247 ++++++++++ .../ConversationMessageContentBuilderTest.kt | 202 +++++++++ .../ConversationMessageDateFormattingTest.kt | 161 +++++++ ...veConversationMessageSimDisplayNameTest.kt | 133 +++--- .../ConversationSimLinkAnnotatedStringTest.kt | 100 ++++ ...onversationMessageTextLinkExtractorTest.kt | 177 ++++++++ 25 files changed, 4186 insertions(+), 72 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateBindingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateMessagesTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateVCardTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/BaseConversationMessageSelectionDelegateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateDeleteTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateMessageActionsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateResendTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSaveAttachmentTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSelectionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateShareTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/BaseConversationMessageUiModelMapperTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMmsDownloadTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperPartsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperStatusTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcherTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilderTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilderTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormattingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/simlink/ConversationSimLinkAnnotatedStringTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationMessageTextLinkExtractorTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt new file mode 100644 index 000000000..6d2f56b1e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt @@ -0,0 +1,198 @@ +package com.android.messaging.ui.conversation.messages.delegate.conversationmessagesdelegate + +import android.net.Uri +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegateImpl +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationMessagesDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected val conversationsRepository = mockk() + protected val messageUiModelMapper = mockk() + protected val vCardUiModelMapper = mockk() + protected val vCardMetadataRepository = mockk() + + protected fun createDelegate(): ConversationMessagesDelegateImpl { + return ConversationMessagesDelegateImpl( + conversationsRepository = conversationsRepository, + conversationMessageUiModelMapper = messageUiModelMapper, + conversationVCardAttachmentUiModelMapper = vCardUiModelMapper, + conversationVCardMetadataRepository = vCardMetadataRepository, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + protected fun TestScope.createBoundDelegate( + conversationIdFlow: StateFlow, + ): ConversationMessagesDelegateImpl { + return createDelegate().also { delegate -> + delegate.bind(scope = backgroundScope, conversationIdFlow = conversationIdFlow) + } + } + + protected fun givenConversationMessages( + messages: Flow>, + conversationId: String = CONVERSATION_ID, + ) { + every { + conversationsRepository.getConversationMessages(conversationId = conversationId) + } returns messages + } + + protected fun givenVCardMetadata( + contentUri: String, + metadata: Flow, + ) { + every { + vCardMetadataRepository.observeAttachmentMetadata(contentUri = contentUri) + } returns metadata + } + + protected fun givenVCardUiModel( + metadata: ConversationVCardAttachmentMetadata?, + uiModel: ConversationVCardAttachmentUiModel, + ) { + every { + vCardUiModelMapper.map(metadata = metadata) + } returns uiModel + } + + protected fun messagesOf( + vararg uiModels: ConversationMessageUiModel, + ): List { + return uiModels.map { uiModel -> + mockk(relaxed = true).also { data -> + every { messageUiModelMapper.map(data = data) } returns uiModel + } + } + } + + protected fun messageUiModel( + messageId: String, + parts: List = emptyList(), + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = messageId, + conversationId = CONVERSATION_ID, + text = "Hello", + parts = parts.toImmutableList(), + sentTimestamp = 1L, + receivedTimestamp = 1L, + displayTimestamp = 1L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = 0L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = false, + canDownloadMessage = false, + canForwardMessage = false, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } + + protected fun textPart(text: String = "body"): ConversationMessagePartUiModel.Text { + return ConversationMessagePartUiModel.Text(text = text) + } + + protected fun imagePart( + contentUri: String = "content://media/image/1", + ): ConversationMessagePartUiModel.Attachment.Image { + return ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = "image/jpeg", + contentUri = Uri.parse(contentUri), + width = 640, + height = 480, + ) + } + + protected fun audioPart( + contentUri: String = "content://media/audio/1", + ): ConversationMessagePartUiModel.Attachment.Audio { + return ConversationMessagePartUiModel.Attachment.Audio( + text = null, + contentType = "audio/mpeg", + contentUri = Uri.parse(contentUri), + width = 0, + height = 0, + ) + } + + protected fun filePart( + contentUri: String = "content://media/file/1", + ): ConversationMessagePartUiModel.Attachment.File { + return ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = "application/pdf", + contentUri = Uri.parse(contentUri), + width = 0, + height = 0, + ) + } + + protected fun videoPart( + contentUri: String = "content://media/video/1", + ): ConversationMessagePartUiModel.Attachment.Video { + return ConversationMessagePartUiModel.Attachment.Video( + text = null, + contentType = "video/mp4", + contentUri = Uri.parse(contentUri), + width = 1280, + height = 720, + ) + } + + protected fun vCardPart( + contentUri: String?, + vCardUiModel: ConversationVCardAttachmentUiModel = vCardUiModel(titleText = "original"), + ): ConversationMessagePartUiModel.Attachment.VCard { + return ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = "text/x-vCard", + contentUri = contentUri?.let(Uri::parse), + width = 0, + height = 0, + vCardUiModel = vCardUiModel, + ) + } + + protected fun vCardUiModel(titleText: String): ConversationVCardAttachmentUiModel { + return ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = titleText, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateBindingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateBindingTest.kt new file mode 100644 index 000000000..36d2c59f2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateBindingTest.kt @@ -0,0 +1,208 @@ +package com.android.messaging.ui.conversation.messages.delegate.conversationmessagesdelegate + +import app.cash.turbine.test +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessagesDelegateBindingTest : BaseConversationMessagesDelegateTest() { + + @Test + fun bind_fromLoadingToPresent_emitsBothStates() { + runTest(context = mainDispatcherRule.testDispatcher) { + val message = messageUiModel(messageId = "message-1") + givenConversationMessages(messages = flowOf(messagesOf(message))) + val delegate = createDelegate() + + delegate.state.test { + assertEquals(ConversationMessagesUiState.Loading, awaitItem()) + + delegate.bind( + scope = backgroundScope, + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(message)), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun bind_withNullConversationId_staysLoadingWithoutQueryingRepository() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = createBoundDelegate(conversationIdFlow = MutableStateFlow(null)) + runCurrent() + + assertEquals(ConversationMessagesUiState.Loading, delegate.state.value) + verify(exactly = 0) { + @Suppress("UnusedFlow") + conversationsRepository.getConversationMessages(conversationId = any()) + } + } + } + + @Test + fun bind_calledTwice_ignoresSecondBinding() { + runTest(context = mainDispatcherRule.testDispatcher) { + val message = messageUiModel(messageId = "message-1") + givenConversationMessages(messages = flowOf(messagesOf(message))) + val reboundMessage = messageUiModel(messageId = "rebound") + givenConversationMessages( + messages = flowOf(messagesOf(reboundMessage)), + conversationId = "conversation-rebound", + ) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + delegate.bind( + scope = backgroundScope, + conversationIdFlow = MutableStateFlow("conversation-rebound"), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(message)), + delegate.state.value, + ) + verify(exactly = 0) { + @Suppress("UnusedFlow") + conversationsRepository.getConversationMessages( + conversationId = "conversation-rebound", + ) + } + } + } + + @Test + fun bind_whenConversationIdBecomesNull_resetsToLoading() { + runTest(context = mainDispatcherRule.testDispatcher) { + val message = messageUiModel(messageId = "message-1") + givenConversationMessages(messages = flowOf(messagesOf(message))) + val conversationIdFlow = MutableStateFlow(CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + runCurrent() + + conversationIdFlow.value = null + runCurrent() + + assertEquals(ConversationMessagesUiState.Loading, delegate.state.value) + } + } + + @Test + fun bind_whenConversationIdChanges_reobservesNewConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstMessage = messageUiModel(messageId = "first") + val secondMessage = messageUiModel(messageId = "second") + givenConversationMessages(messages = flowOf(messagesOf(firstMessage))) + givenConversationMessages( + messages = flowOf(messagesOf(secondMessage)), + conversationId = "conversation-2", + ) + val conversationIdFlow = MutableStateFlow(CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + runCurrent() + + conversationIdFlow.value = "conversation-2" + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(secondMessage)), + delegate.state.value, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationsRepository.getConversationMessages(conversationId = CONVERSATION_ID) + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + conversationsRepository.getConversationMessages(conversationId = "conversation-2") + } + } + } + + @Test + fun bind_whenConversationIdChangesToUnloadedConversation_resetsToLoading() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstMessage = messageUiModel(messageId = "first") + givenConversationMessages(messages = flowOf(messagesOf(firstMessage))) + val pendingSecondMessages = MutableSharedFlow>(replay = 1) + givenConversationMessages( + messages = pendingSecondMessages, + conversationId = "conversation-2", + ) + val conversationIdFlow = MutableStateFlow(CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + runCurrent() + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(firstMessage)), + delegate.state.value, + ) + + conversationIdFlow.value = "conversation-2" + runCurrent() + assertEquals(ConversationMessagesUiState.Loading, delegate.state.value) + + val secondMessage = messageUiModel(messageId = "second") + pendingSecondMessages.tryEmit(messagesOf(secondMessage)) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(secondMessage)), + delegate.state.value, + ) + } + } + + @Test + fun bind_whenConversationIdChangesBeforeFirstEmission_ignoresStaleEmission() { + runTest(context = mainDispatcherRule.testDispatcher) { + val pendingFirstMessages = MutableSharedFlow>(replay = 1) + givenConversationMessages(messages = pendingFirstMessages) + val secondMessage = messageUiModel(messageId = "second") + givenConversationMessages( + messages = flowOf(messagesOf(secondMessage)), + conversationId = "conversation-2", + ) + val conversationIdFlow = MutableStateFlow(CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + runCurrent() + assertEquals(ConversationMessagesUiState.Loading, delegate.state.value) + + conversationIdFlow.value = "conversation-2" + runCurrent() + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(secondMessage)), + delegate.state.value, + ) + + pendingFirstMessages.tryEmit(messagesOf(messageUiModel(messageId = "stale-first"))) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(secondMessage)), + delegate.state.value, + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateMessagesTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateMessagesTest.kt new file mode 100644 index 000000000..2eb244428 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateMessagesTest.kt @@ -0,0 +1,108 @@ +package com.android.messaging.ui.conversation.messages.delegate.conversationmessagesdelegate + +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessagesDelegateMessagesTest : BaseConversationMessagesDelegateTest() { + + @Test + fun bind_withEmptyMessages_emitsPresentWithNoMessages() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenConversationMessages(messages = flowOf(emptyList())) + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf()), + delegate.state.value, + ) + } + } + + @Test + fun bind_withMessagesWithoutVCardParts_emitsPresentWithMappedMessages() { + runTest(context = mainDispatcherRule.testDispatcher) { + val first = messageUiModel( + messageId = "first", + parts = listOf(textPart(text = "hi"), imagePart()), + ) + val second = messageUiModel(messageId = "second", parts = listOf(textPart())) + givenConversationMessages(messages = flowOf(messagesOf(first, second))) + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(first, second)), + delegate.state.value, + ) + verify(exactly = 0) { + @Suppress("UnusedFlow") + vCardMetadataRepository.observeAttachmentMetadata(contentUri = any()) + } + } + } + + @Test + fun bind_mapsEveryMessageDataThroughMessageMapper() { + runTest(context = mainDispatcherRule.testDispatcher) { + val first = messageUiModel(messageId = "first") + val second = messageUiModel(messageId = "second") + val messageData = messagesOf(first, second) + givenConversationMessages(messages = flowOf(messageData)) + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(first, second)), + delegate.state.value, + ) + verify(exactly = 1) { messageUiModelMapper.map(data = messageData[0]) } + verify(exactly = 1) { messageUiModelMapper.map(data = messageData[1]) } + } + } + + @Test + fun bind_whenRepositoryEmitsNewMessageList_updatesPresentState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val initial = messageUiModel(messageId = "initial") + val updated = messageUiModel(messageId = "updated") + val messagesFlow = MutableStateFlow(messagesOf(initial)) + givenConversationMessages(messages = messagesFlow) + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(initial)), + delegate.state.value, + ) + + messagesFlow.value = messagesOf(updated) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(updated)), + delegate.state.value, + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateVCardTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateVCardTest.kt new file mode 100644 index 000000000..f70414e78 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateVCardTest.kt @@ -0,0 +1,428 @@ +package com.android.messaging.ui.conversation.messages.delegate.conversationmessagesdelegate + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessagesDelegateVCardTest : BaseConversationMessagesDelegateTest() { + + @Test + fun bind_withVCardPart_observesMetadataAndAppliesMappedUiModel() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentUri = "content://mms/part/vcard-1" + val vCard = vCardPart(contentUri = contentUri, vCardUiModel = vCardUiModel("original")) + val message = messageUiModel(messageId = "m1", parts = listOf(vCard)) + givenConversationMessages(messages = flowOf(messagesOf(message))) + val metadata = ConversationVCardAttachmentMetadata.Loading + givenVCardMetadata(contentUri = contentUri, metadata = flowOf(metadata)) + val mapped = vCardUiModel("mapped") + givenVCardUiModel(metadata = metadata, uiModel = mapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy(parts = persistentListOf(vCard.copy(vCardUiModel = mapped))), + ), + ), + delegate.state.value, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + vCardMetadataRepository.observeAttachmentMetadata(contentUri = contentUri) + } + } + } + + @Test + fun bind_withDuplicateVCardContentUris_observesUriOnceAndUpdatesBothParts() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentUri = "content://mms/part/shared-vcard" + val firstVCard = vCardPart(contentUri = contentUri, vCardUiModel = vCardUiModel("a")) + val secondVCard = vCardPart(contentUri = contentUri, vCardUiModel = vCardUiModel("b")) + val first = messageUiModel(messageId = "first", parts = listOf(firstVCard)) + val second = messageUiModel(messageId = "second", parts = listOf(secondVCard)) + givenConversationMessages(messages = flowOf(messagesOf(first, second))) + val metadata = ConversationVCardAttachmentMetadata.Loading + givenVCardMetadata(contentUri = contentUri, metadata = flowOf(metadata)) + val mapped = vCardUiModel("mapped") + givenVCardUiModel(metadata = metadata, uiModel = mapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + first.copy( + parts = persistentListOf(firstVCard.copy(vCardUiModel = mapped)) + ), + second.copy( + parts = persistentListOf(secondVCard.copy(vCardUiModel = mapped)) + ), + ), + ), + delegate.state.value, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + vCardMetadataRepository.observeAttachmentMetadata(contentUri = contentUri) + } + } + } + + @Test + fun bind_withMultipleDistinctVCardUris_observesEachAndMapsRespectively() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstUri = "content://mms/part/vcard-1" + val secondUri = "content://mms/part/vcard-2" + val firstVCard = vCardPart(contentUri = firstUri, vCardUiModel = vCardUiModel("a")) + val secondVCard = vCardPart(contentUri = secondUri, vCardUiModel = vCardUiModel("b")) + val message = messageUiModel(messageId = "m1", parts = listOf(firstVCard, secondVCard)) + givenConversationMessages(messages = flowOf(messagesOf(message))) + val firstMetadata = ConversationVCardAttachmentMetadata.Loading + val secondMetadata = ConversationVCardAttachmentMetadata.Failed + givenVCardMetadata(contentUri = firstUri, metadata = flowOf(firstMetadata)) + givenVCardMetadata(contentUri = secondUri, metadata = flowOf(secondMetadata)) + val firstMapped = vCardUiModel("mapped-1") + val secondMapped = vCardUiModel("mapped-2") + givenVCardUiModel(metadata = firstMetadata, uiModel = firstMapped) + givenVCardUiModel(metadata = secondMetadata, uiModel = secondMapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy( + parts = persistentListOf( + firstVCard.copy(vCardUiModel = firstMapped), + secondVCard.copy(vCardUiModel = secondMapped), + ), + ), + ), + ), + delegate.state.value, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + vCardMetadataRepository.observeAttachmentMetadata(contentUri = firstUri) + } + verify(exactly = 1) { + @Suppress("UnusedFlow") + vCardMetadataRepository.observeAttachmentMetadata(contentUri = secondUri) + } + } + } + + @Test + fun bind_withNullContentUriVCardAlongsidePresentOne_mapsNullMetadataForNullUriPart() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentUri = "content://mms/part/vcard-present" + val presentVCard = vCardPart( + contentUri = contentUri, + vCardUiModel = vCardUiModel("present"), + ) + val nullUriVCard = vCardPart( + contentUri = null, + vCardUiModel = vCardUiModel("null-original"), + ) + val message = messageUiModel( + messageId = "m1", + parts = listOf(presentVCard, nullUriVCard), + ) + givenConversationMessages(messages = flowOf(messagesOf(message))) + val metadata = ConversationVCardAttachmentMetadata.Loading + givenVCardMetadata(contentUri = contentUri, metadata = flowOf(metadata)) + val presentMapped = vCardUiModel("present-mapped") + val nullMapped = vCardUiModel("null-mapped") + givenVCardUiModel(metadata = metadata, uiModel = presentMapped) + givenVCardUiModel(metadata = null, uiModel = nullMapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy( + parts = persistentListOf( + presentVCard.copy(vCardUiModel = presentMapped), + nullUriVCard.copy(vCardUiModel = nullMapped), + ), + ), + ), + ), + delegate.state.value, + ) + } + } + + @Test + fun bind_withOnlyNullContentUriVCardParts_leavesMessagesUnmodified() { + runTest(context = mainDispatcherRule.testDispatcher) { + val nullUriVCard = vCardPart(contentUri = null, vCardUiModel = vCardUiModel("original")) + val message = messageUiModel(messageId = "m1", parts = listOf(nullUriVCard)) + givenConversationMessages(messages = flowOf(messagesOf(message))) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(message)), + delegate.state.value, + ) + verify(exactly = 0) { + @Suppress("UnusedFlow") + vCardMetadataRepository.observeAttachmentMetadata(contentUri = any()) + } + verify(exactly = 0) { + vCardUiModelMapper.map(metadata = any()) + } + } + } + + @Test + fun bind_withVCardAndNonVCardParts_leavesNonVCardPartsUnchanged() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentUri = "content://mms/part/vcard-mixed" + val text = textPart(text = "caption") + val image = imagePart() + val audio = audioPart() + val file = filePart() + val video = videoPart() + val vCard = vCardPart(contentUri = contentUri, vCardUiModel = vCardUiModel("original")) + val message = messageUiModel( + messageId = "m1", + parts = listOf(text, image, audio, file, video, vCard), + ) + givenConversationMessages(messages = flowOf(messagesOf(message))) + val metadata = ConversationVCardAttachmentMetadata.Loading + givenVCardMetadata(contentUri = contentUri, metadata = flowOf(metadata)) + val mapped = vCardUiModel("mapped") + givenVCardUiModel(metadata = metadata, uiModel = mapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy( + parts = persistentListOf( + text, + image, + audio, + file, + video, + vCard.copy(vCardUiModel = mapped), + ), + ), + ), + ), + delegate.state.value, + ) + } + } + + @Test + fun bind_whenVCardMetadataEmitsNewValue_updatesPresentState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentUri = "content://mms/part/vcard-live" + val vCard = vCardPart(contentUri = contentUri, vCardUiModel = vCardUiModel("original")) + val message = messageUiModel(messageId = "m1", parts = listOf(vCard)) + givenConversationMessages(messages = flowOf(messagesOf(message))) + val loadingMetadata = ConversationVCardAttachmentMetadata.Loading + val failedMetadata = ConversationVCardAttachmentMetadata.Failed + val metadataFlow = MutableStateFlow( + loadingMetadata, + ) + givenVCardMetadata(contentUri = contentUri, metadata = metadataFlow) + val loadingModel = vCardUiModel("loading-model") + val failedModel = vCardUiModel("failed-model") + givenVCardUiModel(metadata = loadingMetadata, uiModel = loadingModel) + givenVCardUiModel(metadata = failedMetadata, uiModel = failedModel) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy( + parts = persistentListOf(vCard.copy(vCardUiModel = loadingModel)) + ), + ), + ), + delegate.state.value, + ) + + metadataFlow.value = failedMetadata + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy( + parts = persistentListOf(vCard.copy(vCardUiModel = failedModel)) + ), + ), + ), + delegate.state.value, + ) + } + } + + @Test + fun bind_withMultipleDistinctVCardUris_staysLoadingUntilEveryMetadataFlowEmits() { + runTest(context = mainDispatcherRule.testDispatcher) { + val firstUri = "content://mms/part/vcard-1" + val secondUri = "content://mms/part/vcard-2" + val firstVCard = vCardPart(contentUri = firstUri, vCardUiModel = vCardUiModel("a")) + val secondVCard = vCardPart(contentUri = secondUri, vCardUiModel = vCardUiModel("b")) + val message = messageUiModel(messageId = "m1", parts = listOf(firstVCard, secondVCard)) + givenConversationMessages(messages = flowOf(messagesOf(message))) + val firstMetadata = ConversationVCardAttachmentMetadata.Loading + val secondMetadata = ConversationVCardAttachmentMetadata.Failed + val pendingSecondMetadata = MutableSharedFlow( + replay = 1, + ) + givenVCardMetadata(contentUri = firstUri, metadata = flowOf(firstMetadata)) + givenVCardMetadata(contentUri = secondUri, metadata = pendingSecondMetadata) + val firstMapped = vCardUiModel("mapped-1") + val secondMapped = vCardUiModel("mapped-2") + givenVCardUiModel(metadata = firstMetadata, uiModel = firstMapped) + givenVCardUiModel(metadata = secondMetadata, uiModel = secondMapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + assertEquals(ConversationMessagesUiState.Loading, delegate.state.value) + + pendingSecondMetadata.tryEmit(secondMetadata) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + message.copy( + parts = persistentListOf( + firstVCard.copy(vCardUiModel = firstMapped), + secondVCard.copy(vCardUiModel = secondMapped), + ), + ), + ), + ), + delegate.state.value, + ) + } + } + + @Test + fun bind_whenMessageListChangesWhileVCardMetadataPending_cancelsStaleMetadataSubscription() { + runTest(context = mainDispatcherRule.testDispatcher) { + val staleUri = "content://mms/part/vcard-stale" + val staleVCard = vCardPart(contentUri = staleUri, vCardUiModel = vCardUiModel("stale")) + val staleMessage = messageUiModel(messageId = "stale", parts = listOf(staleVCard)) + val replacementMessage = messageUiModel( + messageId = "replacement", + parts = listOf(textPart()), + ) + val messagesFlow = MutableStateFlow(messagesOf(staleMessage)) + givenConversationMessages(messages = messagesFlow) + val staleMetadata = ConversationVCardAttachmentMetadata.Loading + val pendingStaleMetadata = MutableSharedFlow( + replay = 1, + ) + givenVCardMetadata(contentUri = staleUri, metadata = pendingStaleMetadata) + givenVCardUiModel(metadata = staleMetadata, uiModel = vCardUiModel("stale-mapped")) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + assertEquals(ConversationMessagesUiState.Loading, delegate.state.value) + + messagesFlow.value = messagesOf(replacementMessage) + runCurrent() + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(replacementMessage)), + delegate.state.value, + ) + + pendingStaleMetadata.tryEmit(staleMetadata) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present(persistentListOf(replacementMessage)), + delegate.state.value, + ) + } + } + + @Test + fun bind_withMixedVCardAndPlainMessages_enrichesOnlyVCardMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentUri = "content://mms/part/vcard-mixed-messages" + val vCard = vCardPart(contentUri = contentUri, vCardUiModel = vCardUiModel("original")) + val vCardMessage = messageUiModel(messageId = "with-vcard", parts = listOf(vCard)) + val plainMessage = messageUiModel( + messageId = "plain", + parts = listOf(textPart(), imagePart()), + ) + givenConversationMessages(messages = flowOf(messagesOf(vCardMessage, plainMessage))) + val metadata = ConversationVCardAttachmentMetadata.Loading + givenVCardMetadata(contentUri = contentUri, metadata = flowOf(metadata)) + val mapped = vCardUiModel("mapped") + givenVCardUiModel(metadata = metadata, uiModel = mapped) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + + assertEquals( + ConversationMessagesUiState.Present( + persistentListOf( + vCardMessage.copy( + parts = persistentListOf(vCard.copy(vCardUiModel = mapped)) + ), + plainMessage, + ), + ), + delegate.state.value, + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/BaseConversationMessageSelectionDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/BaseConversationMessageSelectionDelegateTest.kt new file mode 100644 index 000000000..c992b8928 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/BaseConversationMessageSelectionDelegateTest.kt @@ -0,0 +1,174 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import android.content.ClipboardManager +import android.net.Uri +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegateImpl +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationMessageSelectionDelegateTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected fun createHarness( + actionRequirements: CheckConversationActionRequirements = createActionRequirementsMock(), + ): DelegateHarness { + val dispatcher = mainDispatcherRule.testDispatcher + val scope = TestScope(dispatcher) + val clipboardManager = mockk(relaxed = true) + val conversationAttachmentsRepository = + mockk(relaxed = true) + val conversationMessagesDelegate = mockk() + val createForwardedMessage = mockk() + val conversationsRepository = mockk(relaxed = true) + val messagesStateFlow = MutableStateFlow( + value = ConversationMessagesUiState.Loading, + ) + val conversationIdFlow = MutableStateFlow(CONVERSATION_ID) + + every { conversationMessagesDelegate.state } returns messagesStateFlow + coEvery { + createForwardedMessage.invoke(any(), any()) + } returns null + + val delegate = ConversationMessageSelectionDelegateImpl( + checkConversationActionRequirements = actionRequirements, + clipboardManager = clipboardManager, + conversationAttachmentsRepository = conversationAttachmentsRepository, + conversationMessagesDelegate = conversationMessagesDelegate, + createForwardedMessage = createForwardedMessage, + conversationsRepository = conversationsRepository, + defaultDispatcher = dispatcher, + ) + delegate.bind( + scope = scope, + conversationIdFlow = conversationIdFlow, + ) + + return DelegateHarness( + delegate = delegate, + clipboardManager = clipboardManager, + conversationAttachmentsRepository = conversationAttachmentsRepository, + conversationIdFlow = conversationIdFlow, + conversationsRepository = conversationsRepository, + createForwardedMessage = createForwardedMessage, + messagesStateFlow = messagesStateFlow, + scope = scope, + ) + } + + protected fun createActionRequirementsMock( + initialResult: ConversationActionRequirementsResult = + ConversationActionRequirementsResult.Ready, + ): CheckConversationActionRequirements { + return createActionRequirementsMock(results = listOf(initialResult)) + } + + protected fun createActionRequirementsMock( + results: List, + ): CheckConversationActionRequirements { + val mock = mockk() + every { mock.invoke() } returnsMany results + return mock + } + + protected fun createAttachmentPart(): ConversationMessagePartUiModel.Attachment { + return ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = IMAGE_ATTACHMENT_CONTENT_TYPE, + contentUri = Uri.parse(IMAGE_ATTACHMENT_CONTENT_URI), + width = 640, + height = 480, + ) + } + + protected fun createMessageUiModel( + messageId: String, + text: String? = "Hello", + parts: ImmutableList = persistentListOf(), + canCopyMessageToClipboard: Boolean = false, + canDownloadMessage: Boolean = false, + canForwardMessage: Boolean = false, + canResendMessage: Boolean = false, + canSaveAttachments: Boolean = false, + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = messageId, + conversationId = CONVERSATION_ID, + text = text, + parts = parts, + sentTimestamp = 1L, + receivedTimestamp = 1L, + displayTimestamp = 1L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = 0L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = canCopyMessageToClipboard, + canDownloadMessage = canDownloadMessage, + canForwardMessage = canForwardMessage, + canResendMessage = canResendMessage, + canSaveAttachments = canSaveAttachments, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } + + protected fun createMessagesUiState( + vararg messages: ConversationMessageUiModel, + ): ConversationMessagesUiState.Present { + return ConversationMessagesUiState.Present( + messages = messages.toList().toPersistentList(), + ) + } + + protected data class DelegateHarness( + val delegate: ConversationMessageSelectionDelegateImpl, + val clipboardManager: ClipboardManager, + val conversationAttachmentsRepository: ConversationAttachmentsRepository, + val conversationIdFlow: MutableStateFlow, + val conversationsRepository: ConversationsRepository, + val createForwardedMessage: CreateForwardedMessage, + val messagesStateFlow: MutableStateFlow, + val scope: TestScope, + ) { + fun cancel() { + scope.cancel() + } + } + + companion object { + const val IMAGE_ATTACHMENT_CONTENT_TYPE = "image/jpeg" + const val IMAGE_ATTACHMENT_CONTENT_URI = "content://media/image/1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateDeleteTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateDeleteTest.kt new file mode 100644 index 000000000..d0ddcb86c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateDeleteTest.kt @@ -0,0 +1,98 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSelectionDelegateDeleteTest : + BaseConversationMessageSelectionDelegateTest() { + + @Test + fun deleteAction_showsAndDismissesConfirmation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel(messageId = "message-1"), + createMessageUiModel(messageId = "message-2"), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + harness.delegate.onMessageClick(messageId = "message-2") + advanceUntilIdle() + + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Delete, + ) + advanceUntilIdle() + + assertEquals( + persistentSetOf("message-1", "message-2"), + harness.delegate.state.value.deleteConfirmation?.messageIds, + ) + + harness.delegate.dismissDeleteMessageConfirmation() + advanceUntilIdle() + + assertNull(harness.delegate.state.value.deleteConfirmation) + assertEquals( + persistentSetOf("message-1", "message-2"), + harness.delegate.state.value.selectedMessageIds, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun confirmDeleteSelectedMessages_deletesSelectedMessagesAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel(messageId = "message-1"), + createMessageUiModel(messageId = "message-2"), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + harness.delegate.onMessageClick(messageId = "message-2") + advanceUntilIdle() + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Delete, + ) + advanceUntilIdle() + + harness.delegate.confirmDeleteSelectedMessages() + advanceUntilIdle() + + verify(exactly = 1) { + harness.conversationsRepository.deleteMessages( + messageIds = persistentSetOf("message-1", "message-2"), + ) + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateMessageActionsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateMessageActionsTest.kt new file mode 100644 index 000000000..d5a0058f0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateMessageActionsTest.kt @@ -0,0 +1,206 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import android.content.ClipData +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.message.ConversationMessageDetailsData +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSelectionDelegateMessageActionsTest : + BaseConversationMessageSelectionDelegateTest() { + + @Test + fun copyAction_copiesTextAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + val copiedClipData = slot() + every { + harness.clipboardManager.setPrimaryClip(capture(copiedClipData)) + } just runs + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = "Copied text", + canCopyMessageToClipboard = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Copy, + ) + advanceUntilIdle() + + assertEquals( + "Copied text", + copiedClipData.captured.getItemAt(0).text.toString(), + ) + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun downloadAction_downloadsSelectedMessageAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canDownloadMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Download, + ) + advanceUntilIdle() + + verify(exactly = 1) { + harness.conversationsRepository.downloadMessage(messageId = "message-1") + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun forwardAction_emitsForwardEffectAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + val forwardedMessage = mockk() + coEvery { + harness.createForwardedMessage.invoke( + conversationId = CONVERSATION_ID, + messageId = "message-1", + ) + } returns forwardedMessage + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canForwardMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Forward, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.LaunchForwardMessage( + message = forwardedMessage, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun detailsAction_emitsDetailsEffectAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + val messageDetails = mockk() + val participants = mockk() + val selfParticipant = mockk() + coEvery { + harness.conversationsRepository.getMessageDetailsData( + conversationId = CONVERSATION_ID, + messageId = "message-1", + ) + } returns ConversationMessageDetailsData( + message = messageDetails, + participants = participants, + selfParticipant = selfParticipant, + ) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel(messageId = "message-1"), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Details, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessageDetails( + message = messageDetails, + participants = participants, + selfParticipant = selfParticipant, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateResendTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateResendTest.kt new file mode 100644 index 000000000..380ef8512 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateResendTest.kt @@ -0,0 +1,283 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import android.app.Activity +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSelectionDelegateResendTest : + BaseConversationMessageSelectionDelegateTest() { + + @Test + fun resendAction_resendsSelectedMessageAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canResendMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Resend, + ) + advanceUntilIdle() + + verify(exactly = 1) { + harness.conversationsRepository.resendMessage(messageId = "message-1") + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onMessageResendClick_resendsMessageWithoutSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.delegate.onMessageResendClick(messageId = "message-1") + advanceUntilIdle() + + verify(exactly = 1) { + harness.conversationsRepository.resendMessage(messageId = "message-1") + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun resendAction_whenSmsIsNotCapable_emitsSmsDisabledMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness( + actionRequirements = createActionRequirementsMock( + initialResult = ConversationActionRequirementsResult.SmsNotCapable, + ), + ) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canResendMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Resend, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun resendAction_whenPreferredSmsSimIsMissing_emitsNoPreferredSimMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness( + actionRequirements = createActionRequirementsMock( + initialResult = ConversationActionRequirementsResult.NoPreferredSmsSim, + ), + ) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canResendMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Resend, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun resendAction_whenDefaultSmsRoleIsMissing_promptsAndResendsAfterRoleRequestSucceeds() { + runTest(context = mainDispatcherRule.testDispatcher) { + val actionRequirements = createActionRequirementsMock( + results = listOf( + ConversationActionRequirementsResult.MissingDefaultSmsRole, + ConversationActionRequirementsResult.Ready, + ), + ) + val harness = createHarness(actionRequirements = actionRequirements) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canResendMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Resend, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.RequestDefaultSmsRole(isSending = true), + awaitItem(), + ) + verify(exactly = 0) { + harness.conversationsRepository.resendMessage(any()) + } + + assertTrue( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ), + ) + advanceUntilIdle() + + verify(exactly = 1) { + harness.conversationsRepository.resendMessage(messageId = "message-1") + } + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_withoutPendingResend_returnsFalse() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + assertFalse( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ), + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_whenCanceled_clearsPendingResend() { + runTest(context = mainDispatcherRule.testDispatcher) { + val actionRequirements = createActionRequirementsMock( + initialResult = ConversationActionRequirementsResult.MissingDefaultSmsRole, + ) + val harness = createHarness(actionRequirements = actionRequirements) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + canResendMessage = true, + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Resend, + ) + advanceUntilIdle() + awaitItem() + + assertTrue( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_CANCELED, + ), + ) + advanceUntilIdle() + + assertFalse( + harness.delegate.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ), + ) + advanceUntilIdle() + + verify(exactly = 0) { + harness.conversationsRepository.resendMessage(any()) + } + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSaveAttachmentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSaveAttachmentTest.kt new file mode 100644 index 000000000..cd4b81701 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSaveAttachmentTest.kt @@ -0,0 +1,203 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import android.net.Uri +import app.cash.turbine.test +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.model.SaveAttachmentsResult +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSelectionDelegateSaveAttachmentTest : + BaseConversationMessageSelectionDelegateTest() { + + @Test + fun onMessageLongClick_exposesSaveAttachmentActionWhenCanSaveAttachments() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = null, + canSaveAttachments = true, + parts = persistentListOf( + createAttachmentPart(), + ), + ), + ) + advanceUntilIdle() + + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + assertEquals( + persistentSetOf( + ConversationMessageSelectionAction.Delete, + ConversationMessageSelectionAction.SaveAttachment, + ConversationMessageSelectionAction.Details, + ), + harness.delegate.state.value.availableActions, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun saveAttachmentAction_emitsResultEffectAndClearsSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + val attachments = listOf( + AttachmentToSave( + contentType = IMAGE_ATTACHMENT_CONTENT_TYPE, + contentUri = IMAGE_ATTACHMENT_CONTENT_URI, + ), + ) + every { + harness.conversationAttachmentsRepository.saveAttachmentsToMediaStore( + attachments = attachments, + ) + } returns flowOf( + SaveAttachmentsResult( + imageCount = 1, + videoCount = 0, + otherCount = 0, + failCount = 0, + ), + ) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = null, + canSaveAttachments = true, + parts = persistentListOf( + createAttachmentPart(), + ), + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.SaveAttachment, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowSaveAttachmentsResult( + imageCount = 1, + videoCount = 0, + otherCount = 0, + failCount = 0, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + verify(exactly = 1) { + @Suppress("UnusedFlow") + harness.conversationAttachmentsRepository.saveAttachmentsToMediaStore( + attachments = attachments, + ) + } + } finally { + harness.cancel() + } + } + } + + @Test + fun saveAttachmentAction_skipsAttachmentsWithBlankContentTypeOrNullUri() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + val attachments = listOf( + AttachmentToSave( + contentType = IMAGE_ATTACHMENT_CONTENT_TYPE, + contentUri = IMAGE_ATTACHMENT_CONTENT_URI, + ), + ) + every { + harness.conversationAttachmentsRepository.saveAttachmentsToMediaStore( + attachments = attachments, + ) + } returns flowOf( + SaveAttachmentsResult( + imageCount = 1, + videoCount = 0, + otherCount = 0, + failCount = 0, + ), + ) + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = null, + canSaveAttachments = true, + parts = persistentListOf( + createAttachmentPart(), + ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = "", + contentUri = Uri.parse("content://media/blank"), + width = 0, + height = 0, + ), + ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = "application/pdf", + contentUri = null, + width = 0, + height = 0, + ), + ), + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.SaveAttachment, + ) + advanceUntilIdle() + + verify(exactly = 1) { + @Suppress("UnusedFlow") + harness.conversationAttachmentsRepository.saveAttachmentsToMediaStore( + attachments = attachments, + ) + } + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSelectionTest.kt new file mode 100644 index 000000000..c26ee43c1 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateSelectionTest.kt @@ -0,0 +1,150 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSelectionDelegateSelectionTest : + BaseConversationMessageSelectionDelegateTest() { + + @Test + fun onMessageLongClick_selectsSingleMessageAndExposesSupportedActions() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = "Hello", + canCopyMessageToClipboard = true, + canForwardMessage = true, + ), + ) + advanceUntilIdle() + + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + assertEquals( + persistentSetOf("message-1"), + harness.delegate.state.value.selectedMessageIds, + ) + assertEquals( + persistentSetOf( + ConversationMessageSelectionAction.Delete, + ConversationMessageSelectionAction.Share, + ConversationMessageSelectionAction.Forward, + ConversationMessageSelectionAction.Copy, + ConversationMessageSelectionAction.Details, + ), + harness.delegate.state.value.availableActions, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onMessageClick_doesNothingOutsideSelectionMode() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel(messageId = "message-1"), + ) + advanceUntilIdle() + + harness.delegate.onMessageClick(messageId = "message-1") + advanceUntilIdle() + + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun onMessageClick_togglesSelectionWhenSelectionModeIsActive() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel(messageId = "message-1"), + createMessageUiModel(messageId = "message-2"), + ) + advanceUntilIdle() + + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + harness.delegate.onMessageClick(messageId = "message-2") + advanceUntilIdle() + + assertEquals( + persistentSetOf("message-1", "message-2"), + harness.delegate.state.value.selectedMessageIds, + ) + assertEquals( + persistentSetOf(ConversationMessageSelectionAction.Delete), + harness.delegate.state.value.availableActions, + ) + + harness.delegate.onMessageClick(messageId = "message-1") + advanceUntilIdle() + + assertEquals( + persistentSetOf("message-2"), + harness.delegate.state.value.selectedMessageIds, + ) + } finally { + harness.cancel() + } + } + } + + @Test + fun bind_clearsSelectionWhenConversationChanges() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel(messageId = "message-1"), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Delete, + ) + advanceUntilIdle() + + harness.conversationIdFlow.value = "conversation-2" + advanceUntilIdle() + + assertEquals( + ConversationMessageSelectionUiState(), + harness.delegate.state.value, + ) + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateShareTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateShareTest.kt new file mode 100644 index 000000000..8116479fb --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/selection/ConversationMessageSelectionDelegateShareTest.kt @@ -0,0 +1,103 @@ +package com.android.messaging.ui.conversation.messages.delegate.selection + +import app.cash.turbine.test +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSelectionDelegateShareTest : + BaseConversationMessageSelectionDelegateTest() { + + @Test + fun shareAction_emitsTextShareWhenSelectedMessageHasText() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = "Share me", + canForwardMessage = true, + parts = persistentListOf( + createAttachmentPart(), + ), + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Share, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShareMessage( + attachmentContentType = null, + attachmentContentUri = null, + text = "Share me", + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } + + @Test + fun shareAction_emitsAttachmentShareWhenSelectedMessageHasNoText() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness() + + try { + harness.messagesStateFlow.value = createMessagesUiState( + createMessageUiModel( + messageId = "message-1", + text = null, + canForwardMessage = true, + parts = persistentListOf( + createAttachmentPart(), + ), + ), + ) + advanceUntilIdle() + harness.delegate.onMessageLongClick(messageId = "message-1") + advanceUntilIdle() + + harness.delegate.effects.test { + harness.delegate.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Share, + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShareMessage( + attachmentContentType = IMAGE_ATTACHMENT_CONTENT_TYPE, + attachmentContentUri = IMAGE_ATTACHMENT_CONTENT_URI, + text = null, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.cancel() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt new file mode 100644 index 000000000..bf190f573 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt @@ -0,0 +1,98 @@ +package com.android.messaging.ui.conversation.messages.mapper + +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapperImpl +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationVCardAttachmentUiModelMapperImplTest { + + private val mapper = ConversationVCardAttachmentUiModelMapperImpl() + + @Test + fun map_loading_returnsDefaultContactLoadingUiModel() { + val uiModel = mapper.map( + metadata = ConversationVCardAttachmentMetadata.Loading, + ) + + assertEquals( + ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = null, + titleTextResId = R.string.notification_vcard, + subtitleText = null, + subtitleTextResId = R.string.loading_vcard, + ), + uiModel, + ) + } + + @Test + fun map_loadedContact_usesContactTextsAndFallbackSubtitleResource() { + val uiModel = mapper.map( + metadata = ConversationVCardAttachmentMetadata.Loaded( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + displayName = "Sam Rivera", + details = null, + locationAddress = null, + ), + ) + + assertEquals( + ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + titleTextResId = null, + subtitleText = null, + subtitleTextResId = R.string.vcard_tap_hint, + ), + uiModel, + ) + } + + @Test + fun map_loadedLocation_prefersLocationAddressAndFallbackLocationTitle() { + val uiModel = mapper.map( + metadata = ConversationVCardAttachmentMetadata.Loaded( + type = ConversationVCardAttachmentType.LOCATION, + avatarUri = null, + displayName = null, + details = "New York", + locationAddress = "25 11th Ave New York NY 10011 United States", + ), + ) + + assertEquals( + ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.LOCATION, + titleText = null, + titleTextResId = R.string.notification_location, + subtitleText = "25 11th Ave New York NY 10011 United States", + subtitleTextResId = null, + ), + uiModel, + ) + } + + @Test + fun map_failed_returnsFailedSubtitleResource() { + val uiModel = mapper.map( + metadata = ConversationVCardAttachmentMetadata.Failed, + ) + + assertEquals( + ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = null, + titleTextResId = R.string.notification_vcard, + subtitleText = null, + subtitleTextResId = R.string.failed_loading_vcard, + ), + uiModel, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/BaseConversationMessageUiModelMapperTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/BaseConversationMessageUiModelMapperTest.kt new file mode 100644 index 000000000..0f08944d6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/BaseConversationMessageUiModelMapperTest.kt @@ -0,0 +1,106 @@ +package com.android.messaging.ui.conversation.messages.mapper.conversationmessage + +import android.net.Uri +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapperImpl +import io.mockk.every +import io.mockk.mockk + +internal abstract class BaseConversationMessageUiModelMapperTest { + + protected val vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + ) + + protected val conversationVCardAttachmentUiModelMapper = + mockk { + every { map(metadata = null) } returns vCardUiModel + } + + protected val mapper = ConversationMessageUiModelMapperImpl( + conversationVCardAttachmentUiModelMapper = conversationVCardAttachmentUiModelMapper, + ) + + protected fun messageData( + messageId: String? = "message-1", + conversationId: String? = "conversation-1", + text: String? = null, + parts: List? = emptyList(), + sentTimestamp: Long = 0L, + receivedTimestamp: Long = 0L, + isIncoming: Boolean = false, + status: Int = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, + senderDisplayName: String? = null, + senderAvatarUri: Uri? = null, + senderContactId: Long = 0L, + senderContactLookupKey: String? = null, + senderNormalizedDestination: String? = null, + senderParticipantId: String? = null, + selfParticipantId: String? = null, + canClusterWithPrevious: Boolean = false, + canClusterWithNext: Boolean = false, + canCopyMessageToClipboard: Boolean = false, + showDownloadMessage: Boolean = false, + canForwardMessage: Boolean = false, + showResendMessage: Boolean = false, + smsMessageSize: Int = 0, + mmsExpiry: Long = 0L, + mmsSubject: String? = null, + isSms: Boolean = false, + isMmsNotification: Boolean = false, + isMms: Boolean = false, + ): ConversationMessageData { + val mock = mockk() + every { mock.messageId } returns messageId + every { mock.conversationId } returns conversationId + every { mock.text } returns text + every { mock.parts } returns parts + every { mock.sentTimeStamp } returns sentTimestamp + every { mock.receivedTimeStamp } returns receivedTimestamp + every { mock.isIncoming } returns isIncoming + every { mock.status } returns status + every { mock.senderDisplayName } returns senderDisplayName + every { mock.senderProfilePhotoUri } returns senderAvatarUri + every { mock.senderContactId } returns senderContactId + every { mock.senderContactLookupKey } returns senderContactLookupKey + every { mock.senderNormalizedDestination } returns senderNormalizedDestination + every { mock.participantId } returns senderParticipantId + every { mock.selfParticipantId } returns selfParticipantId + every { mock.canClusterWithPreviousMessage } returns canClusterWithPrevious + every { mock.canClusterWithNextMessage } returns canClusterWithNext + every { mock.canCopyMessageToClipboard } returns canCopyMessageToClipboard + every { mock.showDownloadMessage } returns showDownloadMessage + every { mock.canForwardMessage } returns canForwardMessage + every { mock.showResendMessage } returns showResendMessage + every { mock.smsMessageSize } returns smsMessageSize + every { mock.mmsExpiry } returns mmsExpiry + every { mock.mmsSubject } returns mmsSubject + every { mock.isSms } returns isSms + every { mock.isMmsNotification } returns isMmsNotification + every { mock.isMms } returns isMms + + return mock + } + + protected fun messagePart( + contentType: String? = "text/plain", + text: String? = null, + contentUri: Uri? = null, + width: Int = 0, + height: Int = 0, + ): MessagePartData { + val mock = mockk() + every { mock.contentType } returns contentType + every { mock.text } returns text + every { mock.contentUri } returns contentUri + every { mock.width } returns width + every { mock.height } returns height + return mock + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt new file mode 100644 index 000000000..b2436539b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt @@ -0,0 +1,116 @@ +package com.android.messaging.ui.conversation.messages.mapper.conversationmessage + +import android.net.Uri +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Protocol +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageUiModelMapperMappingTest : + BaseConversationMessageUiModelMapperTest() { + + @Test + fun map_mapsEveryScalarFieldOntoUiModel() { + val avatarUri = Uri.parse("content://avatar/7") + + val uiModel = mapper.map( + messageData( + messageId = "message-7", + conversationId = "conversation-3", + text = "Hello there", + parts = emptyList(), + sentTimestamp = 1_000L, + receivedTimestamp = 2_000L, + isIncoming = false, + status = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED, + senderDisplayName = "Ada Lovelace", + senderAvatarUri = avatarUri, + senderContactId = 42L, + senderContactLookupKey = "lookup-7", + senderNormalizedDestination = "+15550100", + senderParticipantId = "participant-7", + selfParticipantId = "self-7", + canClusterWithPrevious = true, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + showDownloadMessage = false, + canForwardMessage = true, + showResendMessage = false, + mmsSubject = "Subject line", + isSms = true, + ), + ) + + assertEquals( + ConversationMessageUiModel( + messageId = "message-7", + conversationId = "conversation-3", + text = "Hello there", + parts = persistentListOf(), + sentTimestamp = 1_000L, + receivedTimestamp = 2_000L, + displayTimestamp = 1_000L, + status = Status.Outgoing.Delivered, + isIncoming = false, + senderDisplayName = "Ada Lovelace", + senderAvatarUri = avatarUri, + senderContactId = 42L, + senderContactLookupKey = "lookup-7", + senderNormalizedDestination = "+15550100", + senderParticipantId = "participant-7", + selfParticipantId = "self-7", + canClusterWithPrevious = true, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = "Subject line", + protocol = Protocol.SMS, + ), + uiModel, + ) + } + + @Test + fun map_withNullMessageIdAndConversationId_substitutesEmptyStrings() { + val uiModel = mapper.map( + messageData(messageId = null, conversationId = null), + ) + + assertEquals("", uiModel.messageId) + assertEquals("", uiModel.conversationId) + } + + @Test + fun map_withNullParts_returnsEmptyPartsList() { + val uiModel = mapper.map(messageData(parts = null)) + + assertEquals(persistentListOf(), uiModel.parts) + } + + @Test + fun map_withBlankSenderIdentifiers_mapsThemToNull() { + val uiModel = mapper.map( + messageData( + senderNormalizedDestination = " ", + senderParticipantId = "", + selfParticipantId = " ", + ), + ) + + assertNull(uiModel.senderNormalizedDestination) + assertNull(uiModel.senderParticipantId) + assertNull(uiModel.selfParticipantId) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMmsDownloadTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMmsDownloadTest.kt new file mode 100644 index 000000000..04ce8ad3f --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMmsDownloadTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.conversation.messages.mapper.conversationmessage + +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageUiModelMapperMmsDownloadTest : + BaseConversationMessageUiModelMapperTest() { + + @Test + fun map_awaitingManualDownloadStatus_buildsMmsDownloadWithSizeAndExpiry() { + val uiModel = mapper.map( + messageData( + status = MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD, + smsMessageSize = 2_048, + mmsExpiry = 1_700_000_000_000L, + ), + ) + + assertEquals( + MmsDownloadUiModel( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + sizeBytes = 2_048L, + expiryTimestamp = 1_700_000_000_000L, + ), + uiModel.mmsDownload, + ) + } + + @Test + fun map_downloadingStatuses_buildDownloadingMmsDownload() { + val downloadingStatuses = listOf( + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING, + MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD, + ) + + downloadingStatuses.forEach { status -> + val uiModel = mapper.map(messageData(status = status)) + + assertEquals( + "status=$status", + MmsDownloadUiModel.State.Downloading, + uiModel.mmsDownload?.state, + ) + } + } + + @Test + fun map_downloadFailedStatus_buildsDownloadFailedMmsDownload() { + val uiModel = mapper.map( + messageData(status = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED), + ) + + assertEquals( + MmsDownloadUiModel.State.DownloadFailed, + uiModel.mmsDownload?.state, + ) + } + + @Test + fun map_expiredStatus_buildsExpiredOrUnavailableMmsDownload() { + val uiModel = mapper.map( + messageData(status = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE), + ) + + assertEquals( + MmsDownloadUiModel.State.ExpiredOrUnavailable, + uiModel.mmsDownload?.state, + ) + } + + @Test + fun map_statusWithoutDownloadState_hasNoMmsDownload() { + val nonDownloadStatuses = listOf( + MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, + MessageData.BUGLE_STATUS_UNKNOWN, + ) + + nonDownloadStatuses.forEach { status -> + val uiModel = mapper.map(messageData(status = status)) + + assertNull("status=$status", uiModel.mmsDownload) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperPartsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperPartsTest.kt new file mode 100644 index 000000000..d48148e4f --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperPartsTest.kt @@ -0,0 +1,407 @@ +package com.android.messaging.ui.conversation.messages.mapper.conversationmessage + +import android.net.Uri +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageUiModelMapperPartsTest : + BaseConversationMessageUiModelMapperTest() { + + @Test + fun map_textPart_mapsToTextPartWithPartText() { + val uiModel = mapper.map( + messageData( + parts = listOf(messagePart(contentType = "text/plain", text = "Hi Ada")), + ), + ) + + assertEquals( + persistentListOf(ConversationMessagePartUiModel.Text(text = "Hi Ada")), + uiModel.parts, + ) + } + + @Test + fun map_textPartWithNullText_mapsToTextPartWithEmptyText() { + val uiModel = mapper.map( + messageData( + parts = listOf(messagePart(contentType = "text/html", text = null)), + ), + ) + + assertEquals( + persistentListOf(ConversationMessagePartUiModel.Text(text = "")), + uiModel.parts, + ) + } + + @Test + fun map_audioPart_mapsToAudioAttachmentPreservingMediaFields() { + val contentUri = Uri.parse("content://audio/1") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "audio/mpeg", + text = "voice note", + contentUri = contentUri, + width = 3, + height = 4, + ), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.Audio( + text = "voice note", + contentType = "audio/mpeg", + contentUri = contentUri, + width = 3, + height = 4, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_imagePart_mapsToImageAttachment() { + val contentUri = Uri.parse("content://image/1") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "image/jpeg", + text = null, + contentUri = contentUri, + width = 640, + height = 480, + ), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = "image/jpeg", + contentUri = contentUri, + width = 640, + height = 480, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_videoPart_mapsToVideoAttachment() { + val contentUri = Uri.parse("content://video/1") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "video/mp4", + contentUri = contentUri, + width = 1920, + height = 1080, + ), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.Video( + text = null, + contentType = "video/mp4", + contentUri = contentUri, + width = 1920, + height = 1080, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_vCardPart_mapsToVCardAttachmentUsingVCardMapper() { + val contentUri = Uri.parse("content://vcard/1") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart(contentType = "text/x-vCard", contentUri = contentUri), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = "text/x-vCard", + contentUri = contentUri, + width = 0, + height = 0, + vCardUiModel = vCardUiModel, + ), + ), + uiModel.parts, + ) + verify(exactly = 1) { + conversationVCardAttachmentUiModelMapper.map(metadata = null) + } + } + + @Test + fun map_unknownContentType_mapsToFileAttachment() { + val contentUri = Uri.parse("content://file/1") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart(contentType = "application/pdf", contentUri = contentUri), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = "application/pdf", + contentUri = contentUri, + width = 0, + height = 0, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_nullContentType_mapsToFileAttachmentWithEmptyContentType() { + val uiModel = mapper.map( + messageData(parts = listOf(messagePart(contentType = null))), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = "", + contentUri = null, + width = 0, + height = 0, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_multipleParts_preservesOrderAndTypes() { + val imageUri = Uri.parse("content://image/2") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart(contentType = "text/plain", text = "caption"), + messagePart(contentType = "image/png", contentUri = imageUri), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Text(text = "caption"), + ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = "image/png", + contentUri = imageUri, + width = 0, + height = 0, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_withMediaPartHavingContentUri_marksAttachmentsSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "image/jpeg", + contentUri = Uri.parse("content://image/saveable"), + ), + ), + ), + ) + + assertTrue(uiModel.canSaveAttachments) + } + + @Test + fun map_withTextPartsOnly_marksAttachmentsNotSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "text/plain", + text = "Hi", + contentUri = Uri.parse("content://text/1"), + ), + ), + ), + ) + + assertFalse(uiModel.canSaveAttachments) + } + + @Test + fun map_withMediaPartMissingContentUri_marksAttachmentsNotSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf(messagePart(contentType = "image/jpeg", contentUri = null)), + ), + ) + + assertFalse(uiModel.canSaveAttachments) + } + + @Test + fun map_withBlankContentTypePart_marksAttachmentsNotSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = " ", + contentUri = Uri.parse("content://blank/1"), + ), + ), + ), + ) + + assertFalse(uiModel.canSaveAttachments) + } + + @Test + fun map_withTextCaptionBeforeSaveableMediaParts_marksAttachmentsSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart(contentType = "text/plain", text = "caption"), + messagePart( + contentType = "image/jpeg", + contentUri = Uri.parse("content://image/mixed"), + ), + ), + ), + ) + + assertTrue(uiModel.canSaveAttachments) + } + + @Test + fun map_withSaveableMediaBeforeTextCaptionParts_marksAttachmentsSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "image/jpeg", + contentUri = Uri.parse("content://image/mixed"), + ), + messagePart(contentType = "text/plain", text = "caption"), + ), + ), + ) + + assertTrue(uiModel.canSaveAttachments) + } + + @Test + fun map_withVCardPartHavingContentUri_marksAttachmentsSaveable() { + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart( + contentType = "text/x-vCard", + contentUri = Uri.parse("content://vcard/saveable"), + ), + ), + ), + ) + + assertTrue(uiModel.canSaveAttachments) + } + + @Test + fun map_oggAudioPart_mapsToAudioAttachment() { + val contentUri = Uri.parse("content://audio/ogg") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart(contentType = "application/ogg", contentUri = contentUri), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.Audio( + text = null, + contentType = "application/ogg", + contentUri = contentUri, + width = 0, + height = 0, + ), + ), + uiModel.parts, + ) + } + + @Test + fun map_vCardPartWithLowercaseContentType_mapsToVCardAttachment() { + val contentUri = Uri.parse("content://vcard/lowercase") + + val uiModel = mapper.map( + messageData( + parts = listOf( + messagePart(contentType = "text/x-vcard", contentUri = contentUri), + ), + ), + ) + + assertEquals( + persistentListOf( + ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = "text/x-vcard", + contentUri = contentUri, + width = 0, + height = 0, + vCardUiModel = vCardUiModel, + ), + ), + uiModel.parts, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt new file mode 100644 index 000000000..90224f35f --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.conversation.messages.mapper.conversationmessage + +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Protocol +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageUiModelMapperProtocolAndTimestampTest : + BaseConversationMessageUiModelMapperTest() { + + @Test + fun map_smsMessage_mapsToSmsProtocol() { + val uiModel = mapper.map( + messageData(isSms = true, isMms = false, isMmsNotification = false), + ) + + assertEquals(Protocol.SMS, uiModel.protocol) + } + + @Test + fun map_mmsNotification_mapsToMmsPushNotificationProtocolBeforeMms() { + val uiModel = mapper.map( + messageData(isSms = false, isMmsNotification = true, isMms = true), + ) + + assertEquals(Protocol.MMS_PUSH_NOTIFICATION, uiModel.protocol) + } + + @Test + fun map_mmsMessage_mapsToMmsProtocol() { + val uiModel = mapper.map( + messageData(isSms = false, isMmsNotification = false, isMms = true), + ) + + assertEquals(Protocol.MMS, uiModel.protocol) + } + + @Test + fun map_nonTelephonyMessage_mapsToUnknownProtocol() { + val uiModel = mapper.map( + messageData(isSms = false, isMmsNotification = false, isMms = false), + ) + + assertEquals(Protocol.UNKNOWN, uiModel.protocol) + } + + @Test + fun map_incomingMessage_usesReceivedTimestampAsDisplayTimestamp() { + val uiModel = mapper.map( + messageData(isIncoming = true, receivedTimestamp = 500L, sentTimestamp = 100L), + ) + + assertEquals(500L, uiModel.displayTimestamp) + } + + @Test + fun map_incomingMessageWithoutReceivedTimestamp_fallsBackToSentTimestamp() { + val uiModel = mapper.map( + messageData(isIncoming = true, receivedTimestamp = 0L, sentTimestamp = 100L), + ) + + assertEquals(100L, uiModel.displayTimestamp) + } + + @Test + fun map_outgoingMessage_usesSentTimestampAsDisplayTimestamp() { + val uiModel = mapper.map( + messageData(isIncoming = false, sentTimestamp = 300L, receivedTimestamp = 900L), + ) + + assertEquals(300L, uiModel.displayTimestamp) + } + + @Test + fun map_outgoingMessageWithoutSentTimestamp_fallsBackToReceivedTimestamp() { + val uiModel = mapper.map( + messageData(isIncoming = false, sentTimestamp = 0L, receivedTimestamp = 900L), + ) + + assertEquals(900L, uiModel.displayTimestamp) + } + + @Test + fun map_outgoingMessageWithNegativeSentTimestamp_fallsBackToReceivedTimestamp() { + val uiModel = mapper.map( + messageData(isIncoming = false, sentTimestamp = -1L, receivedTimestamp = 900L), + ) + + assertEquals(900L, uiModel.displayTimestamp) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperStatusTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperStatusTest.kt new file mode 100644 index 000000000..d2a18e5ff --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperStatusTest.kt @@ -0,0 +1,56 @@ +package com.android.messaging.ui.conversation.messages.mapper.conversationmessage + +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageUiModelMapperStatusTest : + BaseConversationMessageUiModelMapperTest() { + + @Test + fun map_mapsEachBugleStatusToMatchingUiStatus() { + val statusToUiStatus = mapOf( + MessageData.BUGLE_STATUS_UNKNOWN to Status.Unknown, + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE to Status.Outgoing.Complete, + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED to Status.Outgoing.Delivered, + MessageData.BUGLE_STATUS_OUTGOING_DRAFT to Status.Outgoing.Draft, + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND to Status.Outgoing.YetToSend, + MessageData.BUGLE_STATUS_OUTGOING_SENDING to Status.Outgoing.Sending, + MessageData.BUGLE_STATUS_OUTGOING_RESENDING to Status.Outgoing.Resending, + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY to Status.Outgoing.AwaitingRetry, + MessageData.BUGLE_STATUS_OUTGOING_FAILED to Status.Outgoing.Failed, + MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER to + Status.Outgoing.FailedEmergencyNumber, + MessageData.BUGLE_STATUS_INCOMING_COMPLETE to Status.Incoming.Complete, + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD to + Status.Incoming.YetToManualDownload, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD to + Status.Incoming.RetryingManualDownload, + MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING to + Status.Incoming.ManualDownloading, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD to + Status.Incoming.RetryingAutoDownload, + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING to Status.Incoming.AutoDownloading, + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED to Status.Incoming.DownloadFailed, + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE to + Status.Incoming.ExpiredOrNotAvailable, + ) + + statusToUiStatus.forEach { (bugleStatus, expectedUiStatus) -> + val uiModel = mapper.map(messageData(status = bugleStatus)) + + assertEquals("status=$bugleStatus", expectedUiStatus, uiModel.status) + } + } + + @Test + fun map_withUnexpectedStatus_mapsToUnknown() { + val uiModel = mapper.map(messageData(status = 9999)) + + assertEquals(Status.Unknown, uiModel.status) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcherTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcherTest.kt new file mode 100644 index 000000000..8a48f1f92 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcherTest.kt @@ -0,0 +1,110 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import android.net.Uri +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationAttachmentActionDispatcherTest { + + @Test + fun dispatchOpenContent_callsAttachmentCallback() { + var openedContentType: String? = null + var openedContentUri: String? = null + var externalUri: String? = null + + dispatchConversationAttachmentOpenAction( + action = ConversationAttachmentOpenAction.OpenContent( + contentType = "image/jpeg", + contentUri = CONTENT_URI, + ), + onAttachmentClick = { contentType, contentUri -> + openedContentType = contentType + openedContentUri = contentUri + }, + onExternalUriClick = { uri -> externalUri = uri }, + ) + + assertEquals("image/jpeg", openedContentType) + assertEquals(CONTENT_URI, openedContentUri) + assertNull(externalUri) + } + + @Test + fun dispatchOpenExternal_callsExternalUriCallback() { + var openedContentUri: String? = null + var externalUri: String? = null + + dispatchConversationAttachmentOpenAction( + action = ConversationAttachmentOpenAction.OpenExternal(uri = EXTERNAL_URI), + onAttachmentClick = { _, contentUri -> openedContentUri = contentUri }, + onExternalUriClick = { uri -> externalUri = uri }, + ) + + assertNull(openedContentUri) + assertEquals(EXTERNAL_URI, externalUri) + } + + @Test + fun mediaAttachmentOpenAction_opensContent() { + val action = ConversationMessageAttachment.Media( + key = "image", + part = ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = "image/jpeg", + contentUri = Uri.parse(CONTENT_URI), + width = 640, + height = 480, + ), + ).toConversationAttachmentOpenActionOrNull() + + assertEquals( + ConversationAttachmentOpenAction.OpenContent( + contentType = "image/jpeg", + contentUri = CONTENT_URI, + ), + action, + ) + } + + @Test + fun unsupportedAttachmentWithoutContentUri_hasNoOpenAction() { + val action = ConversationMessageAttachment.Unsupported( + key = "unsupported", + part = ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = "application/octet-stream", + contentUri = null, + width = 0, + height = 0, + ), + ).toConversationAttachmentOpenActionOrNull() + + assertNull(action) + } + + @Test + fun youTubePreviewOpenAction_opensExternalSourceUrl() { + val action = ConversationMessageAttachment.YouTubePreview( + key = "youtube", + sourceUrl = EXTERNAL_URI, + thumbnailUrl = "https://img.youtube.com/vi/abc/0.jpg", + ).toConversationAttachmentOpenActionOrNull() + + assertEquals( + ConversationAttachmentOpenAction.OpenExternal(uri = EXTERNAL_URI), + action, + ) + } + + private companion object { + private const val CONTENT_URI = "content://mms/part/image-1" + private const val EXTERNAL_URI = "https://www.youtube.com/watch?v=abc" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilderTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilderTest.kt new file mode 100644 index 000000000..945f7819b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilderTest.kt @@ -0,0 +1,247 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import android.net.Uri +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import kotlinx.collections.immutable.toImmutableList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationAttachmentSectionsBuilderTest { + + @Test + fun mixedAttachments_splitsGalleryVisualsAndTrailingItems() { + val imageAttachment = ConversationMessageAttachment.Media( + key = "image", + part = imagePart(), + ) + val videoAttachment = ConversationMessageAttachment.Media( + key = "video", + part = videoPart(), + ) + val youTubeAttachment = ConversationMessageAttachment.YouTubePreview( + key = "youtube", + sourceUrl = "https://www.youtube.com/watch?v=abc", + thumbnailUrl = "https://img.youtube.com/vi/abc/0.jpg", + ) + val unsupportedAttachment = ConversationMessageAttachment.Unsupported( + key = "unsupported", + part = filePart( + contentUri = Uri.parse("content://mms/part/file-1"), + contentType = "application/pdf", + ), + ) + + val sections = buildConversationAttachmentSections( + attachments = listOf( + imageAttachment, + videoAttachment, + youTubeAttachment, + unsupportedAttachment, + ).toImmutableList(), + ) + + assertEquals( + listOf(imageAttachment, youTubeAttachment), + sections.galleryVisualAttachments, + ) + assertEquals(2, sections.trailingItems.size) + assertEquals( + videoAttachment, + (sections.trailingItems[0] as ConversationAttachmentItem.StandaloneVisual) + .attachment, + ) + + val fileAttachment = (sections.trailingItems[1] as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.File + assertEquals("unsupported", fileAttachment.key) + assertEquals("application/pdf", fileAttachment.titleText) + } + + @Test + fun audioAttachment_mapsToInlineAudioAttachment() { + val sections = buildConversationAttachmentSections( + attachments = listOf( + ConversationMessageAttachment.Media( + key = "attachment-1", + part = ConversationMessagePartUiModel.Attachment.Audio( + text = null, + contentType = "audio/x-wav", + contentUri = Uri.parse("content://mms/part/audio-1"), + width = 0, + height = 0, + ), + ), + ).toImmutableList(), + ) + + val inlineAttachment = + (sections.trailingItems.single() as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.Audio + + assertEquals("content://mms/part/audio-1", inlineAttachment.contentUri) + assertEquals(R.string.audio_attachment_content_description, inlineAttachment.titleTextResId) + assertNull(inlineAttachment.titleText) + } + + @Test + fun vcardAttachment_mapsToInlineVCardAttachment_andPreservesUiModel() { + val vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.LOCATION, + titleText = "Pier 57", + subtitleText = "25 11th Ave New York NY 10011 United States", + ) + + val sections = buildConversationAttachmentSections( + attachments = listOf( + ConversationMessageAttachment.Media( + key = "attachment-1", + part = ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = "text/x-vCard", + contentUri = Uri.parse("content://mms/part/vcard-1"), + width = 0, + height = 0, + vCardUiModel = vCardUiModel, + ), + ), + ).toImmutableList(), + ) + + val inlineAttachment = + (sections.trailingItems.single() as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.VCard + + assertEquals("content://mms/part/vcard-1", inlineAttachment.contentUri) + assertEquals(ConversationVCardAttachmentType.LOCATION, inlineAttachment.type) + assertEquals("Pier 57", inlineAttachment.titleText) + assertEquals("25 11th Ave New York NY 10011 United States", inlineAttachment.subtitleText) + } + + @Test + fun vcardAttachment_usesSubtitleOverrideWhenProvided() { + val vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + subtitleText = "sam@example.com", + ) + + val sections = buildConversationAttachmentSections( + attachments = listOf( + ConversationMessageAttachment.Media( + key = "attachment-1", + part = ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = "text/x-vCard", + contentUri = Uri.parse("content://mms/part/vcard-1"), + width = 0, + height = 0, + vCardUiModel = vCardUiModel, + ), + ), + ).toImmutableList(), + vCardSubtitleTextResIdOverride = R.string.copy_to_clipboard, + ) + + val inlineAttachment = + (sections.trailingItems.single() as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.VCard + + assertNull(inlineAttachment.subtitleText) + assertEquals(R.string.copy_to_clipboard, inlineAttachment.subtitleTextResId) + } + + @Test + fun unsupportedAttachmentWithoutContentUri_hasNoOpenAction() { + val sections = buildConversationAttachmentSections( + attachments = listOf( + ConversationMessageAttachment.Unsupported( + key = "unsupported", + part = filePart( + contentUri = null, + contentType = "", + ), + ), + ).toImmutableList(), + ) + + val inlineAttachment = + (sections.trailingItems.single() as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.File + + assertNull(inlineAttachment.openAction) + assertNull(inlineAttachment.titleText) + assertTrue(sections.galleryVisualAttachments.isEmpty()) + } + + @Test + fun mediaFileAttachment_mapsToInlineFileAttachmentWithOpenAction() { + val sections = buildConversationAttachmentSections( + attachments = listOf( + ConversationMessageAttachment.Media( + key = "file", + part = filePart( + contentUri = Uri.parse("content://mms/part/file-1"), + contentType = "application/pdf", + ), + ), + ).toImmutableList(), + ) + + val inlineAttachment = + (sections.trailingItems.single() as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.File + + assertEquals("file", inlineAttachment.key) + assertEquals("application/pdf", inlineAttachment.titleText) + assertEquals( + "content://mms/part/file-1", + (inlineAttachment.openAction as ConversationAttachmentOpenAction.OpenContent) + .contentUri, + ) + } + + private fun imagePart(): ConversationMessagePartUiModel.Attachment.Image { + return ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = "image/jpeg", + contentUri = Uri.parse("content://mms/part/image-1"), + width = 640, + height = 480, + ) + } + + private fun videoPart(): ConversationMessagePartUiModel.Attachment.Video { + return ConversationMessagePartUiModel.Attachment.Video( + text = null, + contentType = "video/mp4", + contentUri = Uri.parse("content://mms/part/video-1"), + width = 640, + height = 480, + ) + } + + private fun filePart( + contentUri: Uri?, + contentType: String, + ): ConversationMessagePartUiModel.Attachment.File { + return ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = contentType, + contentUri = contentUri, + width = 0, + height = 0, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilderTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilderTest.kt new file mode 100644 index 000000000..8d90d424e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilderTest.kt @@ -0,0 +1,202 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.net.Uri +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationMessageContentBuilderTest { + + @Test + fun attachmentOnlyAudioMessage_doesNotUseMimeTypeAsBodyText() { + val message = createMessage( + text = null, + parts = persistentListOf( + createAudioPart(), + ), + ) + + val content = buildConversationMessageContent( + message = message, + subjectText = null, + ) + + assertNull(content.bodyText) + assertTrue(content.isAttachmentOnly) + assertEquals(1, content.attachmentSections.trailingItems.size) + + val attachment = content.attachmentSections.trailingItems.single() + as ConversationAttachmentItem.Inline + + val inlineAttachment = attachment.attachment as ConversationInlineAttachment.Audio + assertEquals(AUDIO_CONTENT_URI, inlineAttachment.contentUri) + } + + @Test + fun attachmentOnlyVCardMessage_buildsInlineVCardAttachment_withoutBodyText() { + val vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + subtitleText = "sam@example.com", + ) + val message = createMessage( + text = null, + parts = persistentListOf( + createVCardPart( + vCardUiModel = vCardUiModel, + ), + ), + ) + + val content = buildConversationMessageContent( + message = message, + subjectText = null, + ) + + assertNull(content.bodyText) + assertTrue(content.isAttachmentOnly) + + val trailingAttachment = content.attachmentSections.trailingItems.single() + + val inlineAttachment = (trailingAttachment as ConversationAttachmentItem.Inline) + .attachment as ConversationInlineAttachment.VCard + assertEquals(V_CARD_CONTENT_URI, inlineAttachment.contentUri) + assertEquals(ConversationVCardAttachmentType.CONTACT, inlineAttachment.type) + assertEquals("Sam Rivera", inlineAttachment.titleText) + assertEquals("sam@example.com", inlineAttachment.subtitleText) + } + + @Test + fun messageText_isUsedAsBodyText() { + val message = createMessage( + text = " See you soon ", + parts = persistentListOf(), + ) + + val content = buildConversationMessageContent( + message = message, + subjectText = null, + ) + + assertEquals("See you soon", content.bodyText) + assertFalse(content.isAttachmentOnly) + } + + @Test + fun messageText_takesPrecedenceOverAttachmentCaption() { + val message = createMessage( + text = "Message body", + parts = persistentListOf( + createAudioPart( + text = "Attachment caption", + ), + ), + ) + + val content = buildConversationMessageContent( + message = message, + subjectText = null, + ) + + assertEquals("Message body", content.bodyText) + assertFalse(content.isAttachmentOnly) + } + + @Test + fun attachmentCaption_isUsedAsBodyText() { + val message = createMessage( + text = null, + parts = persistentListOf( + createAudioPart( + text = "Ambient room tone", + ), + ), + ) + + val content = buildConversationMessageContent( + message = message, + subjectText = null, + ) + + assertEquals("Ambient room tone", content.bodyText) + assertFalse(content.isAttachmentOnly) + } + + private fun createMessage( + text: String?, + parts: ImmutableList, + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = "message-1", + conversationId = CONVERSATION_ID, + text = text, + parts = parts, + sentTimestamp = 1L, + receivedTimestamp = 1L, + displayTimestamp = 1L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = 0L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.MMS, + ) + } + + private fun createAudioPart( + text: String? = null, + ): ConversationMessagePartUiModel.Attachment.Audio { + return ConversationMessagePartUiModel.Attachment.Audio( + text = text, + contentType = "audio/x-wav", + contentUri = Uri.parse(AUDIO_CONTENT_URI), + width = 0, + height = 0, + ) + } + + private fun createVCardPart( + vCardUiModel: ConversationVCardAttachmentUiModel, + ): ConversationMessagePartUiModel.Attachment.VCard { + return ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = "text/x-vCard", + contentUri = Uri.parse(V_CARD_CONTENT_URI), + width = 0, + height = 0, + vCardUiModel = vCardUiModel, + ) + } + + private companion object { + private const val AUDIO_CONTENT_URI = "content://mms/part/audio-1" + private const val V_CARD_CONTENT_URI = "content://mms/part/vcard-1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormattingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormattingTest.kt new file mode 100644 index 000000000..65468f2a9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormattingTest.kt @@ -0,0 +1,161 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.content.Context +import android.text.format.DateUtils +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import java.time.LocalDate +import java.time.ZoneId +import java.util.TimeZone +import kotlinx.collections.immutable.persistentListOf +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +internal class ConversationMessageDateFormattingTest { + + @After + fun tearDown() { + unmockkStatic(DateUtils::class) + } + + @Test + fun displayEpochDay_returnsNullForNonPositiveTimestamp() { + assertNull( + conversationMessageDisplayEpochDay( + displayTimestamp = 0L, + timeZone = TimeZone.getTimeZone("UTC"), + ), + ) + assertNull( + conversationMessageDisplayEpochDay( + displayTimestamp = -1L, + timeZone = TimeZone.getTimeZone("UTC"), + ), + ) + } + + @Test + fun displayEpochDay_usesProvidedTimezoneOffset() { + val timestamp = 64_800_000L + + assertEquals( + 0L, + conversationMessageDisplayEpochDay( + displayTimestamp = timestamp, + timeZone = TimeZone.getTimeZone("UTC"), + ), + ) + assertEquals( + 1L, + conversationMessageDisplayEpochDay( + displayTimestamp = timestamp, + timeZone = TimeZone.getTimeZone("GMT+12"), + ), + ) + } + + @Test + fun formatDateSeparatorText_returnsNullForNonPositiveTimestamp() { + assertNull( + formatDateSeparatorText( + context = mockk(), + message = message(displayTimestamp = 0L), + ), + ) + } + + @Test + fun formatDateSeparatorText_omitsYearForCurrentYearTimestamp() { + mockkStatic(DateUtils::class) + val context = mockk() + val timestamp = timestampForDate(date = LocalDate.now()) + val expectedFlags = DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_MONTH or + DateUtils.FORMAT_NO_YEAR + every { + DateUtils.formatDateTime( + context, + timestamp, + expectedFlags, + ) + } returns "Today" + + assertEquals( + "Today", + formatDateSeparatorText( + context = context, + message = message(displayTimestamp = timestamp), + ), + ) + } + + @Test + fun formatDateSeparatorText_includesYearForDifferentYearTimestamp() { + mockkStatic(DateUtils::class) + val context = mockk() + val timestamp = timestampForDate(date = LocalDate.now().minusYears(1)) + val expectedFlags = DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_MONTH or + DateUtils.FORMAT_SHOW_YEAR + every { + DateUtils.formatDateTime( + context, + timestamp, + expectedFlags, + ) + } returns "Last year" + + assertEquals( + "Last year", + formatDateSeparatorText( + context = context, + message = message(displayTimestamp = timestamp), + ), + ) + } + + private fun timestampForDate(date: LocalDate): Long { + return date + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + + private fun message(displayTimestamp: Long): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = "message-1", + conversationId = "conversation-1", + text = "Hello", + parts = persistentListOf(), + sentTimestamp = displayTimestamp, + receivedTimestamp = displayTimestamp, + displayTimestamp = displayTimestamp, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = 0L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt index 64ec15fac..6904f3df2 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt @@ -9,16 +9,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -private const val SIM_1_ID = "self-sim-1" -private const val SIM_2_ID = "self-sim-2" -private const val SIM_1_NAME = "SIM 1" -private const val SIM_2_NAME = "SIM 2" - -private val SIM_DISPLAY_NAMES = mapOf( - SIM_1_ID to SIM_1_NAME, - SIM_2_ID to SIM_2_NAME, -) - class ResolveConversationMessageSimDisplayNameTest { @Test @@ -34,19 +24,6 @@ class ResolveConversationMessageSimDisplayNameTest { assertNull(result) } - @Test - fun returnsNullWhenMessageHasNoSelfParticipantId() { - val message = message(selfParticipantId = null, isIncoming = true) - - val result = resolveConversationMessageSimDisplayName( - message = message, - messageBelow = null, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, - ) - - assertNull(result) - } - @Test fun returnsNullWhenSelfParticipantIdMissingFromMap() { val message = message(selfParticipantId = "unknown", isIncoming = false) @@ -54,7 +31,7 @@ class ResolveConversationMessageSimDisplayNameTest { val result = resolveConversationMessageSimDisplayName( message = message, messageBelow = null, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + simDisplayNameByParticipantId = simDisplayNames, ) assertNull(result) @@ -67,7 +44,7 @@ class ResolveConversationMessageSimDisplayNameTest { val result = resolveConversationMessageSimDisplayName( message = message, messageBelow = null, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + simDisplayNameByParticipantId = simDisplayNames, ) assertEquals(SIM_2_NAME, result) @@ -81,7 +58,7 @@ class ResolveConversationMessageSimDisplayNameTest { val result = resolveConversationMessageSimDisplayName( message = message, messageBelow = below, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + simDisplayNameByParticipantId = simDisplayNames, ) assertEquals(SIM_1_NAME, result) @@ -95,7 +72,7 @@ class ResolveConversationMessageSimDisplayNameTest { val result = resolveConversationMessageSimDisplayName( message = message, messageBelow = below, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + simDisplayNameByParticipantId = simDisplayNames, ) assertEquals(SIM_1_NAME, result) @@ -109,7 +86,7 @@ class ResolveConversationMessageSimDisplayNameTest { val result = resolveConversationMessageSimDisplayName( message = message, messageBelow = below, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + simDisplayNameByParticipantId = simDisplayNames, ) assertNull(result) @@ -127,54 +104,66 @@ class ResolveConversationMessageSimDisplayNameTest { val result = resolveConversationMessageSimDisplayName( message = message, messageBelow = below, - simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + simDisplayNameByParticipantId = simDisplayNames, ) assertEquals(SIM_1_NAME, result) } -} -private fun message( - selfParticipantId: String?, - isIncoming: Boolean, - mmsDownload: MmsDownloadUiModel? = null, -): ConversationMessageUiModel { - return ConversationMessageUiModel( - messageId = "id-${selfParticipantId.orEmpty()}-$isIncoming", - conversationId = "conversation", - text = "text", - parts = persistentListOf( - ConversationMessagePartUiModel.Text(text = "text"), - ), - sentTimestamp = 0L, - receivedTimestamp = 0L, - displayTimestamp = 0L, - status = ConversationMessageUiModel.Status.Outgoing.Complete, - isIncoming = isIncoming, - senderDisplayName = null, - senderAvatarUri = null, - senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, - senderContactLookupKey = null, - senderNormalizedDestination = null, - senderParticipantId = null, - selfParticipantId = selfParticipantId, - canClusterWithPrevious = false, - canClusterWithNext = false, - canCopyMessageToClipboard = false, - canDownloadMessage = false, - canForwardMessage = false, - canResendMessage = false, - canSaveAttachments = false, - mmsDownload = mmsDownload, - mmsSubject = null, - protocol = ConversationMessageUiModel.Protocol.SMS, - ) -} + private fun message( + selfParticipantId: String?, + isIncoming: Boolean, + mmsDownload: MmsDownloadUiModel? = null, + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = "id-${selfParticipantId.orEmpty()}-$isIncoming", + conversationId = "conversation", + text = "text", + parts = persistentListOf( + ConversationMessagePartUiModel.Text(text = "text"), + ), + sentTimestamp = 0L, + receivedTimestamp = 0L, + displayTimestamp = 0L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = isIncoming, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = selfParticipantId, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = false, + canDownloadMessage = false, + canForwardMessage = false, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = mmsDownload, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } -private fun mmsDownload(): MmsDownloadUiModel { - return MmsDownloadUiModel( - state = MmsDownloadUiModel.State.AwaitingManualDownload, - sizeBytes = 0L, - expiryTimestamp = 0L, - ) + private fun mmsDownload(): MmsDownloadUiModel { + return MmsDownloadUiModel( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + sizeBytes = 0L, + expiryTimestamp = 0L, + ) + } + + private companion object { + private const val SIM_1_ID = "self-sim-1" + private const val SIM_2_ID = "self-sim-2" + private const val SIM_1_NAME = "SIM 1" + private const val SIM_2_NAME = "SIM 2" + + private val simDisplayNames = mapOf( + SIM_1_ID to SIM_1_NAME, + SIM_2_ID to SIM_2_NAME, + ) + } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/simlink/ConversationSimLinkAnnotatedStringTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/simlink/ConversationSimLinkAnnotatedStringTest.kt new file mode 100644 index 000000000..ff7ebd633 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/simlink/ConversationSimLinkAnnotatedStringTest.kt @@ -0,0 +1,100 @@ +package com.android.messaging.ui.conversation.messages.ui.simlink + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.style.TextDecoration +import com.android.messaging.ui.conversation.messages.ui.buildConversationSimLinkAnnotatedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private val LINK_COLOR = Color(0xFF336699) + +internal class ConversationSimLinkAnnotatedStringTest { + + @Test + fun buildConversationSimLinkAnnotatedString_placesClickableSimNameInTemplate() { + var clickCount = 0 + + val result = buildConversationSimLinkAnnotatedString( + annotationTemplate = $$"via %1$s now", + simDisplayName = "Work SIM", + linkColor = LINK_COLOR, + onSimSelectorClick = { clickCount++ }, + isLinkEnabled = true, + leadingText = "Queued", + leadingSeparator = " · ", + ) + + val linkStart = result.text.indexOf("Work SIM") + val linkEnd = linkStart + "Work SIM".length + val linkAnnotationRange = result.getLinkAnnotations( + start = linkStart, + end = linkEnd, + ).single() + val linkAnnotation = linkAnnotationRange.item as LinkAnnotation.Clickable + + assertEquals("Queued · via Work SIM now", result.text) + assertEquals(linkStart, linkAnnotationRange.start) + assertEquals(linkEnd, linkAnnotationRange.end) + assertEquals("sim_selector", linkAnnotation.tag) + assertTrue(LINK_COLOR == linkAnnotation.styles?.style?.color) + assertEquals(TextDecoration.Underline, linkAnnotation.styles?.style?.textDecoration) + + linkAnnotation.linkInteractionListener?.onClick(linkAnnotation) + + assertEquals(1, clickCount) + } + + @Test + fun buildConversationSimLinkAnnotatedString_omitsLinkAnnotationWhenDisabled() { + val result = buildConversationSimLinkAnnotatedString( + annotationTemplate = $$"via %1$s", + simDisplayName = "Personal SIM", + linkColor = LINK_COLOR, + onSimSelectorClick = {}, + isLinkEnabled = false, + ) + + assertEquals("via Personal SIM", result.text) + assertFalse( + result.hasLinkAnnotations( + start = 0, + end = result.text.length, + ), + ) + } + + @Test + fun buildConversationSimLinkAnnotatedString_appendsSimNameWhenTemplateHasNoPlaceholder() { + val result = buildConversationSimLinkAnnotatedString( + annotationTemplate = "Sending from ", + simDisplayName = "Travel SIM", + linkColor = LINK_COLOR, + onSimSelectorClick = {}, + ) + + assertEquals("Sending from Travel SIM", result.text) + assertTrue( + result.hasLinkAnnotations( + start = "Sending from ".length, + end = result.text.length, + ), + ) + } + + @Test + fun buildConversationSimLinkAnnotatedString_skipsLeadingSeparatorWhenLeadingTextIsEmpty() { + val result = buildConversationSimLinkAnnotatedString( + annotationTemplate = $$"%1$s", + simDisplayName = "Only SIM", + linkColor = LINK_COLOR, + onSimSelectorClick = {}, + leadingText = "", + leadingSeparator = " · ", + ) + + assertEquals("Only SIM", result.text) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationMessageTextLinkExtractorTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationMessageTextLinkExtractorTest.kt new file mode 100644 index 000000000..48d003b06 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationMessageTextLinkExtractorTest.kt @@ -0,0 +1,177 @@ +package com.android.messaging.ui.conversation.messages.ui.text.links + +import android.content.Context +import android.net.Uri +import android.view.textclassifier.TextClassificationManager +import android.view.textclassifier.TextClassifier +import android.view.textclassifier.TextLinks +import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink +import com.android.messaging.ui.conversation.messages.ui.text.extractConversationTextLinks +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageTextLinkExtractorTest { + + @Test + fun extractConversationTextLinks_returnsEmptyListForBlankTextWithoutClassifierLookup() { + val context = mockk(relaxed = true) + + val result = extractConversationTextLinks( + context = context, + text = " ", + ) + + assertTrue(result.isEmpty()) + verify(exactly = 0) { + context.getSystemService(TextClassificationManager::class.java) + } + } + + @Test + fun extractConversationTextLinks_mapsSupportedEntitiesAndSortsByStartOffset() { + val text = "Email a@b.com, call +15551212, go to https://example.com/a, meet at 1 Main St" + val emailRange = text.rangeOf("a@b.com") + val phoneRange = text.rangeOf("+15551212") + val urlRange = text.rangeOf("https://example.com/a") + val addressRange = text.rangeOf("1 Main St") + val textLinks = buildTextLinks( + text = text, + LinkSpec(range = urlRange, entityType = TextClassifier.TYPE_URL), + LinkSpec(range = phoneRange, entityType = TextClassifier.TYPE_PHONE), + LinkSpec(range = emailRange, entityType = TextClassifier.TYPE_EMAIL), + LinkSpec(range = addressRange, entityType = TextClassifier.TYPE_ADDRESS), + ) + val requestSlot = slot() + val context = createContextWithClassifier( + textLinks = textLinks, + requestSlot = requestSlot, + ) + + val result = extractConversationTextLinks( + context = context, + text = text, + ) + + assertEquals(text, requestSlot.captured.text.toString()) + assertEquals( + listOf( + ConversationTextLink( + start = emailRange.first, + end = emailRange.last + 1, + url = "mailto:${Uri.encode("a@b.com")}", + ), + ConversationTextLink( + start = phoneRange.first, + end = phoneRange.last + 1, + url = "tel:${Uri.encode("+15551212")}", + ), + ConversationTextLink( + start = urlRange.first, + end = urlRange.last + 1, + url = "https://example.com/a", + ), + ConversationTextLink( + start = addressRange.first, + end = addressRange.last + 1, + url = "geo:0,0?q=${Uri.encode("1 Main St")}", + ), + ), + result, + ) + } + + @Test + fun extractConversationTextLinks_skipsBlankAndUnsupportedLinks() { + val text = "bad ok unsupported" + val blankRange = text.rangeOf(" ") + val validRange = text.rangeOf("ok") + val unsupportedRange = text.rangeOf("unsupported") + val textLinks = buildTextLinks( + text = text, + LinkSpec(range = blankRange, entityType = TextClassifier.TYPE_EMAIL), + LinkSpec(range = unsupportedRange, entityType = "unsupported"), + LinkSpec(range = validRange, entityType = TextClassifier.TYPE_PHONE), + ) + val context = createContextWithClassifier(textLinks = textLinks) + + val result = extractConversationTextLinks( + context = context, + text = text, + ) + + assertEquals( + listOf( + ConversationTextLink( + start = validRange.first, + end = validRange.last + 1, + url = "tel:${Uri.encode("ok")}", + ), + ), + result, + ) + } + + @Test + fun extractConversationTextLinks_usesNoOpClassifierWhenSystemServiceIsMissing() { + val context = mockk() + every { + context.getSystemService(TextClassificationManager::class.java) + } returns null + + val result = extractConversationTextLinks( + context = context, + text = "Call +15551212", + ) + + assertTrue(result.isEmpty()) + } + + private fun createContextWithClassifier( + textLinks: TextLinks, + requestSlot: CapturingSlot? = null, + ): Context { + val context = mockk() + val manager = mockk() + val classifier = mockk() + every { context.getSystemService(TextClassificationManager::class.java) } returns manager + every { manager.textClassifier } returns classifier + if (requestSlot != null) { + every { classifier.generateLinks(capture(requestSlot)) } returns textLinks + } else { + every { classifier.generateLinks(any()) } returns textLinks + } + return context + } + + private fun buildTextLinks(text: String, vararg specs: LinkSpec): TextLinks { + val builder = TextLinks.Builder(text) + specs.forEach { spec -> + builder.addLink( + spec.range.first, + spec.range.last + 1, + mapOf(spec.entityType to 1.0f), + ) + } + return builder.build() + } + + private fun String.rangeOf(value: String): IntRange { + val start = indexOf(value) + check(start >= 0) + return start until (start + value.length) + } + + private data class LinkSpec( + val range: IntRange, + val entityType: String, + ) +} From b332468138c0081d1e7f1c2223c381eafb42e777 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 12:39:48 +0300 Subject: [PATCH 10/38] Add conversation screen unit tests --- .../ConversationNavigationReducerImplTest.kt | 231 +++++++++ .../ConversationAutoScrollPolicyTest.kt | 117 +++++ .../screen/ConversationSimSheetStateTest.kt | 45 ++ .../MessagePositionToDisplayIndexTest.kt | 33 ++ ...ConversationAttachmentPreviewEffectTest.kt | 212 +++++++++ .../ConversationShareSheetEffectTest.kt | 132 ++++++ .../BaseConversationViewModelTest.kt | 446 ++++++++++++++++++ ...versationViewModelAttachmentPreviewTest.kt | 99 ++++ .../ConversationViewModelBindingTest.kt | 129 +++++ .../ConversationViewModelCallActionTest.kt | 194 ++++++++ ...ConversationViewModelDefaultSmsRoleTest.kt | 201 ++++++++ .../ConversationViewModelDelegationTest.kt | 193 ++++++++ .../ConversationViewModelEffectRelayTest.kt | 115 +++++ ...ersationViewModelMessageInteractionTest.kt | 100 ++++ .../ConversationViewModelUiStateTest.kt | 242 ++++++++++ 15 files changed, 2489 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducerImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicyTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationSimSheetStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/MessagePositionToDisplayIndexTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationAttachmentPreviewEffectTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationShareSheetEffectTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelAttachmentPreviewTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelBindingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelCallActionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDefaultSmsRoleTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDelegationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelEffectRelayTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelMessageInteractionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelUiStateTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducerImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducerImplTest.kt new file mode 100644 index 000000000..05835fafe --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducerImplTest.kt @@ -0,0 +1,231 @@ +package com.android.messaging.ui.conversation.navigation + +import androidx.navigation3.runtime.NavKey +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConversationNavigationReducerImplTest { + + private val reducer: ConversationNavigationReducer = ConversationNavigationReducerImpl() + + @Test + fun navigateToConversation_replacesNewChatEntryFlowWithConversation() { + val backStack = mutableListOf(NewChatNavKey) + + reducer.navigateToConversation( + backStack = backStack, + conversationId = CONVERSATION_ID, + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + ), + backStack, + ) + } + + @Test + fun navigateToConversation_removesRecipientPickerEntryFlowBeforeNavigating() { + val backStack = mutableListOf( + NewChatNavKey, + RecipientPickerNavKey(mode = RecipientPickerMode.CREATE_GROUP), + ) + + reducer.navigateToConversation( + backStack = backStack, + conversationId = CONVERSATION_ID, + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + ), + backStack, + ) + } + + @Test + fun navigateToConversation_appendsWhenAlreadyInsideConversationFlow() { + val backStack = mutableListOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + ) + + reducer.navigateToConversation( + backStack = backStack, + conversationId = "conversation-2", + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + ConversationNavKey(conversationId = "conversation-2"), + ), + backStack, + ) + } + + @Test + fun navigateToRecipientPicker_doesNotDuplicateExistingTopDestination() { + val backStack = mutableListOf( + NewChatNavKey, + RecipientPickerNavKey(mode = RecipientPickerMode.ADD_PARTICIPANTS), + ) + + reducer.navigateToRecipientPicker( + backStack = backStack, + mode = RecipientPickerMode.ADD_PARTICIPANTS, + ) + + assertEquals( + listOf( + NewChatNavKey, + RecipientPickerNavKey(mode = RecipientPickerMode.ADD_PARTICIPANTS), + ), + backStack, + ) + } + + @Test + fun navigateToAddParticipants_appendsDestinationWhenItIsNotAlreadyOnTop() { + val backStack = mutableListOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + ) + + reducer.navigateToAddParticipants( + backStack = backStack, + conversationId = CONVERSATION_ID, + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + AddParticipantsNavKey(conversationId = CONVERSATION_ID), + ), + backStack, + ) + } + + @Test + fun navigateToAddParticipants_doesNotDuplicateExistingTopDestination() { + val backStack = mutableListOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + AddParticipantsNavKey(conversationId = CONVERSATION_ID), + ) + + reducer.navigateToAddParticipants( + backStack = backStack, + conversationId = CONVERSATION_ID, + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + AddParticipantsNavKey(conversationId = CONVERSATION_ID), + ), + backStack, + ) + } + + @Test + fun popBackStack_returnsFalseWhenBackStackHasSingleEntry() { + val backStack = mutableListOf(NewChatNavKey) + + val wasPopped = reducer.popBackStack(backStack = backStack) + + assertFalse(wasPopped) + assertEquals(listOf(NewChatNavKey), backStack) + } + + @Test + fun popBackStack_removesLastEntryWhenBackStackHasMultipleEntries() { + val backStack = mutableListOf( + NewChatNavKey, + ConversationNavKey(conversationId = CONVERSATION_ID), + ) + + val wasPopped = reducer.popBackStack(backStack = backStack) + + assertTrue(wasPopped) + assertEquals( + listOf(NewChatNavKey), + backStack, + ) + } + + @Test + fun replaceCurrentConversation_removesAddParticipantsAndReplacesExistingConversation() { + val backStack = mutableListOf( + ConversationNavKey(conversationId = CONVERSATION_ID), + AddParticipantsNavKey(conversationId = CONVERSATION_ID), + ) + + reducer.replaceCurrentConversation( + backStack = backStack, + conversationId = "conversation-2", + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = "conversation-2"), + ), + backStack, + ) + } + + @Test + fun replaceCurrentConversation_addsConversationWhenBackStackHasNoConversationEntry() { + val backStack = mutableListOf( + NewChatNavKey, + AddParticipantsNavKey(conversationId = CONVERSATION_ID), + ) + + reducer.replaceCurrentConversation( + backStack = backStack, + conversationId = "conversation-2", + ) + + assertEquals( + listOf( + NewChatNavKey, + ConversationNavKey(conversationId = "conversation-2"), + ), + backStack, + ) + } + + @Test + fun resetBackStack_keepsSingleMatchingDestinationUntouched() { + val backStack = mutableListOf(NewChatNavKey) + + reducer.resetBackStack( + backStack = backStack, + destination = NewChatNavKey, + ) + + assertEquals(listOf(NewChatNavKey), backStack) + } + + @Test + fun resetBackStack_replacesExistingEntriesWithDestination() { + val backStack = mutableListOf( + NewChatNavKey, + ConversationNavKey(conversationId = CONVERSATION_ID), + ) + + reducer.resetBackStack( + backStack = backStack, + destination = ConversationNavKey(conversationId = "conversation-2"), + ) + + assertEquals( + listOf( + ConversationNavKey(conversationId = "conversation-2"), + ), + backStack, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicyTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicyTest.kt new file mode 100644 index 000000000..4c005cc4b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicyTest.kt @@ -0,0 +1,117 @@ +package com.android.messaging.ui.conversation.screen + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationAutoScrollPolicyTest { + + @Test + fun evaluateConversationAutoScroll_doesNotScrollWhenLatestMessageDidNotChange() { + val decision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = "message-1", + latestMessageId = "message-1", + hasLatestMessage = true, + isLatestMessageIncoming = false, + wasScrolledToLatestMessage = true, + ), + ) + + assertEquals( + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = "message-1", + ), + decision, + ) + } + + @Test + fun evaluateConversationAutoScroll_doesNotScrollWhenThereIsNoLatestMessage() { + val decision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = "message-1", + latestMessageId = null, + hasLatestMessage = false, + isLatestMessageIncoming = false, + wasScrolledToLatestMessage = true, + ), + ) + + assertEquals( + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = null, + ), + decision, + ) + } + + @Test + fun evaluateConversationAutoScroll_showsSnackbarForIncomingMessageWhenUserIsAwayFromLatest() { + val decision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = "message-1", + latestMessageId = "message-2", + hasLatestMessage = true, + isLatestMessageIncoming = true, + wasScrolledToLatestMessage = false, + ), + ) + + assertEquals( + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = true, + updatedLatestMessageId = "message-2", + ), + decision, + ) + } + + @Test + fun evaluateConversationAutoScroll_scrollsForIncomingMessageWhenUserIsAlreadyAtLatest() { + val decision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = "message-1", + latestMessageId = "message-2", + hasLatestMessage = true, + isLatestMessageIncoming = true, + wasScrolledToLatestMessage = true, + ), + ) + + assertEquals( + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = true, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = "message-2", + ), + decision, + ) + } + + @Test + fun evaluateConversationAutoScroll_scrollsForOutgoingMessage() { + val decision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = "message-1", + latestMessageId = "message-2", + hasLatestMessage = true, + isLatestMessageIncoming = false, + wasScrolledToLatestMessage = false, + ), + ) + + assertEquals( + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = true, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = "message-2", + ), + decision, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationSimSheetStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationSimSheetStateTest.kt new file mode 100644 index 000000000..df8338678 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationSimSheetStateTest.kt @@ -0,0 +1,45 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.runtime.saveable.SaverScope +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ConversationSimSheetStateTest { + + @Test + fun showAndDismiss_updateVisibility() { + val state = ConversationSimSheetState() + + assertFalse(state.isVisible) + + state.show() + assertTrue(state.isVisible) + + state.dismiss() + assertFalse(state.isVisible) + } + + @Test + fun saver_roundTripsVisibility() { + val state = ConversationSimSheetState() + state.show() + val saverScope = SaverScope { true } + + val savedState = with(ConversationSimSheetState.Saver) { + with(saverScope) { + save(state) + } + } + + assertNotNull(savedState) + + val restoredState = with(ConversationSimSheetState.Saver) { + restore(savedState!!) + } + + assertNotNull(restoredState) + assertTrue(restoredState!!.isVisible) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/MessagePositionToDisplayIndexTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/MessagePositionToDisplayIndexTest.kt new file mode 100644 index 000000000..f25e0c16c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/MessagePositionToDisplayIndexTest.kt @@ -0,0 +1,33 @@ +package com.android.messaging.ui.conversation.screen + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MessagePositionToDisplayIndexTest { + + @Test + fun position0_inFiveItemList_mapsToLastDisplayIndex() { + assertEquals(4, messagePositionToDisplayIndex(position = 0, size = 5)) + } + + @Test + fun lastPosition_mapsToFirstDisplayIndex() { + assertEquals(0, messagePositionToDisplayIndex(position = 4, size = 5)) + } + + @Test + fun positionAtOrBeyondSize_clampsToZero() { + assertEquals(0, messagePositionToDisplayIndex(position = 5, size = 5)) + assertEquals(0, messagePositionToDisplayIndex(position = 100, size = 5)) + } + + @Test + fun emptyList_clampsToZero() { + assertEquals(0, messagePositionToDisplayIndex(position = 0, size = 0)) + } + + @Test + fun singleItemList_alwaysMapsToZero() { + assertEquals(0, messagePositionToDisplayIndex(position = 0, size = 1)) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationAttachmentPreviewEffectTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationAttachmentPreviewEffectTest.kt new file mode 100644 index 000000000..75eefa4c6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationAttachmentPreviewEffectTest.kt @@ -0,0 +1,212 @@ +package com.android.messaging.ui.conversation.screen.effects + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.Rect +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Rect as ComposeRect +import com.android.messaging.Factory +import com.android.messaging.R +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.screen.openAttachmentPreviewEffect +import com.android.messaging.util.UiUtils +import com.android.messaging.util.UriUtil +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationAttachmentPreviewEffectTest { + + private lateinit var context: Context + private lateinit var applicationContext: Context + private lateinit var resources: Resources + private lateinit var factory: Factory + private lateinit var uiIntents: UIIntents + + @Before + fun setUp() { + context = mockk(relaxed = true) + applicationContext = mockk(relaxed = true) + resources = mockk(relaxed = true) + factory = mockk(relaxed = true) + uiIntents = mockk(relaxed = true) + mockkStatic(Factory::class) + mockkStatic(UIIntents::class) + mockkStatic(UriUtil::class) + every { Factory.get() } returns factory + every { factory.applicationContext } returns applicationContext + every { applicationContext.resources } returns resources + every { resources.getInteger(any()) } returns 0 + every { UIIntents.get() } returns uiIntents + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun openAttachmentPreviewEffect_opensImageInternallyWhenActivityAndCollectionExist() { + runTest { + val activity = mockk(relaxed = true) + val boundsSlot = slot() + + openAttachmentPreviewEffect( + context = activity, + hostBoundsState = mutableStateOf( + ComposeRect(left = 1.2f, top = 2.6f, right = 7.4f, bottom = 8.5f), + ), + effect = ConversationScreenEffect.OpenAttachmentPreview( + contentType = "image/png", + contentUri = "content://media/image/1", + imageCollectionUri = "content://media/images", + ), + ) + + verify(exactly = 1) { + uiIntents.launchFullScreenPhotoViewer( + activity, + Uri.parse("content://media/image/1"), + capture(boundsSlot), + Uri.parse("content://media/images"), + ) + } + assertEquals(Rect(1, 3, 7, 9), boundsSlot.captured) + verify(exactly = 0) { + activity.startActivity(any()) + } + } + } + + @Test + fun openAttachmentPreviewEffect_fallsBackToGenericImageIntentWhenActivityIsUnavailable() { + runTest { + val context = mockk(relaxed = true) + val intentSlot = slot() + every { context.startActivity(capture(intentSlot)) } just runs + + openAttachmentPreviewEffect( + context = context, + hostBoundsState = mutableStateOf( + ComposeRect(left = 0f, top = 0f, right = 10f, bottom = 10f), + ), + effect = ConversationScreenEffect.OpenAttachmentPreview( + contentType = "image/png", + contentUri = "content://media/image/3", + imageCollectionUri = "content://media/images", + ), + ) + + assertEquals(Intent.ACTION_VIEW, intentSlot.captured.action) + assertEquals(Uri.parse("content://media/image/3"), intentSlot.captured.data) + assertEquals("image/png", intentSlot.captured.type) + assertTrue( + intentSlot.captured.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0, + ) + verify(exactly = 0) { + uiIntents.launchFullScreenPhotoViewer(any(), any(), any(), any()) + } + } + } + + @Test + fun openAttachmentPreviewEffect_normalizesFileUriBeforeOpeningVCard() { + runTest { + val fileUri = Uri.parse("file:///tmp/contact.vcf") + val scratchUri = Uri.parse("content://scratch/contact.vcf") + every { UriUtil.persistContentToScratchSpace(fileUri) } returns scratchUri + + openAttachmentPreviewEffect( + context = context, + hostBoundsState = mutableStateOf( + ComposeRect(left = 0f, top = 0f, right = 10f, bottom = 10f), + ), + effect = ConversationScreenEffect.OpenAttachmentPreview( + contentType = "text/x-vcard", + contentUri = fileUri.toString(), + imageCollectionUri = null, + ), + ) + + verify(exactly = 1) { + uiIntents.launchVCardDetailActivity(context, scratchUri) + } + } + } + + @Test + fun openAttachmentPreviewEffect_opensVideoViewerWithNormalizedUri() { + runTest { + val fileUri = Uri.parse("file:///tmp/video.mp4") + every { UriUtil.persistContentToScratchSpace(fileUri) } returns null + + openAttachmentPreviewEffect( + context = context, + hostBoundsState = mutableStateOf( + ComposeRect(left = 0f, top = 0f, right = 10f, bottom = 10f), + ), + effect = ConversationScreenEffect.OpenAttachmentPreview( + contentType = "video/mp4", + contentUri = fileUri.toString(), + imageCollectionUri = null, + ), + ) + + verify(exactly = 1) { + uiIntents.launchFullScreenVideoViewer(context, fileUri) + } + } + } + + @Test + fun openAttachmentPreviewEffect_showsToastWhenGenericIntentCannotBeHandled() { + runTest { + mockkStatic(UiUtils::class) + every { Factory.get() } returns factory + every { factory.applicationContext } returns applicationContext + every { + context.startActivity(any()) + } throws ActivityNotFoundException("no handler") + every { + UiUtils.showToastAtBottom(R.string.activity_not_found_message) + } just runs + + openAttachmentPreviewEffect( + context = context, + hostBoundsState = mutableStateOf( + ComposeRect(left = 0f, top = 0f, right = 10f, bottom = 10f), + ), + effect = ConversationScreenEffect.OpenAttachmentPreview( + contentType = "application/pdf", + contentUri = "content://media/file/1", + imageCollectionUri = null, + ), + ) + + verify(exactly = 1) { + UiUtils.showToastAtBottom(R.string.activity_not_found_message) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationShareSheetEffectTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationShareSheetEffectTest.kt new file mode 100644 index 000000000..2f2b6be81 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationShareSheetEffectTest.kt @@ -0,0 +1,132 @@ +package com.android.messaging.ui.conversation.screen.effects + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.android.messaging.R +import com.android.messaging.ui.conversation.screen.openShareSheet +import com.android.messaging.util.UriUtil +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 kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationShareSheetEffectTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = mockk(relaxed = true) + mockkStatic(UriUtil::class) + every { context.getText(R.string.action_share) } returns "Share" + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun openShareSheet_sharesAttachmentWithReadGrant() { + runTest { + val fileUri = Uri.parse("file:///tmp/image.png") + val scratchUri = Uri.parse("content://scratch/image.png") + val chooserIntentSlot = slot() + every { UriUtil.persistContentToScratchSpace(fileUri) } returns scratchUri + every { context.startActivity(capture(chooserIntentSlot)) } just runs + + openShareSheet( + context = context, + attachmentContentType = "image/png", + attachmentContentUri = fileUri.toString(), + text = "ignored", + ) + + val sendIntent = chooserIntentSlot.captured + .getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + + assertEquals(Intent.ACTION_CHOOSER, chooserIntentSlot.captured.action) + assertEquals( + "Share", + chooserIntentSlot.captured.getCharSequenceExtra(Intent.EXTRA_TITLE), + ) + assertEquals(Intent.ACTION_SEND, sendIntent?.action) + assertEquals("image/png", sendIntent?.type) + assertEquals( + scratchUri, + sendIntent?.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java), + ) + assertNull(sendIntent?.getStringExtra(Intent.EXTRA_TEXT)) + assertTrue( + requireNotNull(sendIntent).flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0, + ) + } + } + + @Test + fun openShareSheet_fallsBackToTextWhenAttachmentFieldsAreBlank() { + runTest { + val chooserIntentSlot = slot() + every { context.startActivity(capture(chooserIntentSlot)) } just runs + + openShareSheet( + context = context, + attachmentContentType = " ", + attachmentContentUri = "content://media/image/1", + text = null, + ) + + val sendIntent = chooserIntentSlot.captured + .getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + + assertEquals(Intent.ACTION_SEND, sendIntent?.action) + assertEquals("text/plain", sendIntent?.type) + assertEquals("", sendIntent?.getStringExtra(Intent.EXTRA_TEXT)) + assertNull(sendIntent?.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)) + verify(exactly = 0) { + UriUtil.persistContentToScratchSpace(any()) + } + } + } + + @Test + fun openShareSheet_keepsFileUriWhenScratchPersistenceFails() { + runTest { + val fileUri = Uri.parse("file:///tmp/video.mp4") + val chooserIntentSlot = slot() + every { UriUtil.persistContentToScratchSpace(fileUri) } returns null + every { context.startActivity(capture(chooserIntentSlot)) } just runs + + openShareSheet( + context = context, + attachmentContentType = "video/mp4", + attachmentContentUri = fileUri.toString(), + text = null, + ) + + val sendIntent = chooserIntentSlot.captured + .getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + + assertEquals( + fileUri, + sendIntent?.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java), + ) + assertEquals("video/mp4", sendIntent?.type) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt new file mode 100644 index 000000000..0ae6071e3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt @@ -0,0 +1,446 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.subscription.model.Subscription +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.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.ConversationViewModel +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Rule + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseConversationViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + protected fun createViewModel( + audioRecordingDelegate: ConversationAudioRecordingDelegate = + createAudioRecordingDelegateMock().mock, + composerAttachmentsDelegate: ConversationComposerAttachmentsDelegate = + createComposerAttachmentsDelegateMock().mock, + draftDelegate: ConversationDraftDelegate = createDraftDelegateMock().mock, + messagesDelegate: ConversationMessagesDelegate = createMessagesDelegateMock().mock, + messageSelectionDelegate: ConversationMessageSelectionDelegate = + createMessageSelectionDelegateMock().mock, + mediaPickerDelegate: ConversationMediaPickerDelegate = createMediaPickerDelegateMock().mock, + metadataDelegate: ConversationMetadataDelegate = createMetadataDelegateMock().mock, + focusDelegate: ConversationFocusDelegate = createFocusDelegateMock().mock, + canAddMoreConversationParticipants: CanAddMoreConversationParticipants = mockk { + every { invoke(participantCount = any()) } returns false + }, + createDefaultSmsRoleRequest: CreateDefaultSmsRoleRequest = CreateDefaultSmsRoleRequest { + null + }, + isDeviceVoiceCapable: IsDeviceVoiceCapable = IsDeviceVoiceCapable { false }, + isEmergencyPhoneNumber: IsEmergencyPhoneNumber = IsEmergencyPhoneNumber { false }, + composerUiStateMapper: ConversationComposerUiStateMapper = + createComposerUiStateMapperMock(mappedUiState = ConversationComposerUiState()), + subscriptionsRepository: SubscriptionsRepository = + createSubscriptionsRepositoryMock(subscriptions = persistentListOf()), + ): ConversationViewModel { + return ConversationViewModel( + conversationAudioRecordingDelegate = audioRecordingDelegate, + conversationComposerAttachmentsDelegate = composerAttachmentsDelegate, + conversationDraftDelegate = draftDelegate, + conversationMessagesDelegate = messagesDelegate, + conversationMessageSelectionDelegate = messageSelectionDelegate, + conversationMediaPickerDelegate = mediaPickerDelegate, + conversationMetadataDelegate = metadataDelegate, + conversationFocusDelegate = focusDelegate, + conversationComposerUiStateMapper = composerUiStateMapper, + subscriptionsRepository = subscriptionsRepository, + canAddMoreConversationParticipants = canAddMoreConversationParticipants, + createDefaultSmsRoleRequest = createDefaultSmsRoleRequest, + isDeviceVoiceCapable = isDeviceVoiceCapable, + isEmergencyPhoneNumber = isEmergencyPhoneNumber, + defaultDispatcher = mainDispatcherRule.testDispatcher, + savedStateHandle = SavedStateHandle(), + ) + } + + protected fun createViewModelInStore( + viewModelStore: ViewModelStore, + viewModelFactory: () -> ConversationViewModel = { createViewModel() }, + ): ConversationViewModel { + return ViewModelProvider( + store = viewModelStore, + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return viewModelFactory() as T + } + }, + )[ConversationViewModel::class.java] + } + + protected fun createOneOnOneMetadataState( + phoneNumber: String = TEST_CALL_ACTION_PHONE_NUMBER, + ): ConversationMetadataUiState.Present { + return ConversationMetadataUiState.Present( + title = "Alice", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single(photoUri = null), + participantCount = 1, + otherParticipantDisplayDestination = phoneNumber, + otherParticipantPhoneNumber = phoneNumber, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + } + + protected fun createComposerAttachmentsDelegateMock(): ComposerAttachmentsDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow>( + persistentListOf(), + ) + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += ComposerAttachmentsBindCall( + scope = firstArg(), + draftStateFlow = secondArg(), + ) + } + return ComposerAttachmentsDelegateMock( + mock = mock, + stateFlow = stateFlow, + bindCalls = bindCalls, + ) + } + + protected fun createAudioRecordingDelegateMock(): AudioRecordingDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow(ConversationAudioRecordingUiState()) + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return AudioRecordingDelegateMock( + mock = mock, + stateFlow = stateFlow, + bindCalls = bindCalls, + ) + } + + protected fun createSubscriptionsRepositoryMock( + subscriptions: ImmutableList, + ): SubscriptionsRepository { + val repository = mockk() + every { + repository.observeActiveSubscriptions() + } returns MutableStateFlow(subscriptions) + return repository + } + + protected fun createDraftDelegateMock(): DraftDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow(ConversationDraftState()) + val attachmentLimitWarningFlow = + MutableStateFlow(null) + val effectsFlow = MutableSharedFlow() + val isSubjectDialogVisibleFlow = MutableStateFlow(false) + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + every { mock.attachmentLimitWarning } returns attachmentLimitWarningFlow + every { mock.effects } returns effectsFlow + every { mock.isSubjectDialogVisible } returns isSubjectDialogVisibleFlow + every { mock.tryStartAddingAttachment() } returns true + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return DraftDelegateMock( + mock = mock, + stateFlow = stateFlow, + effectsFlow = effectsFlow, + bindCalls = bindCalls, + ) + } + + protected fun createMediaPickerDelegateMock(): MediaPickerDelegateMock { + val bindCalls = mutableListOf() + val effectsFlow = MutableSharedFlow() + val photoPickerSourceContentUriByAttachmentContentUriFlow: + MutableStateFlow> = + MutableStateFlow(persistentMapOf()) + val mock = mockk(relaxed = true) + every { mock.effects } returns effectsFlow + every { + mock.photoPickerSourceContentUriByAttachmentContentUri + } returns photoPickerSourceContentUriByAttachmentContentUriFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return MediaPickerDelegateMock( + mock = mock, + effectsFlow = effectsFlow, + photoPickerSourceContentUriByAttachmentContentUriFlow = + photoPickerSourceContentUriByAttachmentContentUriFlow, + bindCalls = bindCalls, + ) + } + + protected fun createMessagesDelegateMock(): MessagesDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow( + ConversationMessagesUiState.Loading, + ) + val mock = mockk() + every { mock.state } returns stateFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return MessagesDelegateMock( + mock = mock, + stateFlow = stateFlow, + bindCalls = bindCalls, + ) + } + + protected fun createMessageSelectionDelegateMock(): MessageSelectionDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow(ConversationMessageSelectionUiState()) + val effectsFlow = MutableSharedFlow() + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + every { mock.effects } returns effectsFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return MessageSelectionDelegateMock( + mock = mock, + stateFlow = stateFlow, + effectsFlow = effectsFlow, + bindCalls = bindCalls, + ) + } + + protected fun createMetadataDelegateMock(): MetadataDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow( + ConversationMetadataUiState.Loading, + ) + val effectsFlow = MutableSharedFlow() + val deleteConfirmationVisibleFlow = MutableStateFlow(value = false) + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + every { mock.effects } returns effectsFlow + every { + mock.isDeleteConversationConfirmationVisible + } returns deleteConfirmationVisibleFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return MetadataDelegateMock( + mock = mock, + stateFlow = stateFlow, + effectsFlow = effectsFlow, + deleteConfirmationVisibleFlow = deleteConfirmationVisibleFlow, + bindCalls = bindCalls, + ) + } + + protected fun createFocusDelegateMock(): FocusDelegateMock { + val bindCalls = mutableListOf() + val mock = mockk(relaxed = true) + every { + mock.bind(any(), any()) + } answers { + bindCalls += FocusBindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return FocusDelegateMock( + mock = mock, + bindCalls = bindCalls, + ) + } + + protected fun createComposerUiStateMapperMock( + mappedUiState: ConversationComposerUiState, + ): ConversationComposerUiStateMapper { + val mapper = mockk() + every { + mapper.map(any(), any(), any(), any(), any(), any()) + } returns mappedUiState + return mapper + } + + protected fun createMessageUiModel(): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = MESSAGE_ID, + conversationId = CONVERSATION_ID, + text = "Hello", + parts = persistentListOf(), + sentTimestamp = 1L, + receivedTimestamp = 1L, + displayTimestamp = 1L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = 0L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = false, + canDownloadMessage = false, + canForwardMessage = false, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } + + protected data class BindCall( + val scope: CoroutineScope, + val conversationIdFlow: StateFlow, + ) + + protected data class DraftDelegateMock( + val mock: ConversationDraftDelegate, + val stateFlow: MutableStateFlow, + val effectsFlow: MutableSharedFlow, + val bindCalls: List, + ) + + protected data class ComposerAttachmentsBindCall( + val scope: CoroutineScope, + val draftStateFlow: StateFlow, + ) + + protected data class ComposerAttachmentsDelegateMock( + val mock: ConversationComposerAttachmentsDelegate, + val stateFlow: MutableStateFlow>, + val bindCalls: List, + ) + + protected data class AudioRecordingDelegateMock( + val mock: ConversationAudioRecordingDelegate, + val stateFlow: MutableStateFlow, + val bindCalls: List, + ) + + protected data class MediaPickerDelegateMock( + val mock: ConversationMediaPickerDelegate, + val effectsFlow: MutableSharedFlow, + val photoPickerSourceContentUriByAttachmentContentUriFlow: + MutableStateFlow>, + val bindCalls: List, + ) + + protected data class MessagesDelegateMock( + val mock: ConversationMessagesDelegate, + val stateFlow: MutableStateFlow, + val bindCalls: List, + ) + + protected data class MessageSelectionDelegateMock( + val mock: ConversationMessageSelectionDelegate, + val stateFlow: MutableStateFlow, + val effectsFlow: MutableSharedFlow, + val bindCalls: List, + ) + + protected data class MetadataDelegateMock( + val mock: ConversationMetadataDelegate, + val stateFlow: MutableStateFlow, + val effectsFlow: MutableSharedFlow, + val deleteConfirmationVisibleFlow: MutableStateFlow, + val bindCalls: List, + ) + + protected data class FocusBindCall( + val scope: CoroutineScope, + val conversationIdFlow: StateFlow, + ) + + protected data class FocusDelegateMock( + val mock: ConversationFocusDelegate, + val bindCalls: List, + ) + + protected companion object { + const val EMERGENCY_PHONE_NUMBER = "911" + const val MESSAGE_ID = "message-1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelAttachmentPreviewTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelAttachmentPreviewTest.kt new file mode 100644 index 000000000..572212bab --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelAttachmentPreviewTest.kt @@ -0,0 +1,99 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import app.cash.turbine.test +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelAttachmentPreviewTest : BaseConversationViewModelTest() { + + @Test + fun onOpenStartupAttachment_emitsAttachmentPreviewForConversationImages() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + + viewModel.effects.test { + viewModel.onOpenStartupAttachment( + conversationId = CONVERSATION_ID, + startupAttachment = ConversationEntryStartupAttachment( + contentType = "image/jpeg", + contentUri = "content://media/image/1", + ), + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = "image/jpeg", + contentUri = "content://media/image/1", + imageCollectionUri = MessagingContentProvider + .buildConversationImagesUri(CONVERSATION_ID) + .toString(), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun attachmentPreviewEvents_useDraftAndConversationImageCollections() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + viewModel.onConversationIdChanged(conversationId = CONVERSATION_ID) + + viewModel.effects.test { + viewModel.onAttachmentClicked( + attachment = ComposerAttachmentUiModel.Resolved.VisualMedia.Image( + key = "attachment-1", + contentType = "image/jpeg", + contentUri = "content://media/image/1", + captionText = "", + width = 640, + height = 480, + ), + ) + advanceUntilIdle() + assertEquals( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = "image/jpeg", + contentUri = "content://media/image/1", + imageCollectionUri = MessagingContentProvider + .buildDraftImagesUri(CONVERSATION_ID) + .toString(), + ), + awaitItem(), + ) + + viewModel.onMessageAttachmentClicked( + contentType = "image/png", + contentUri = "content://media/image/2", + ) + advanceUntilIdle() + assertEquals( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = "image/png", + contentUri = "content://media/image/2", + imageCollectionUri = MessagingContentProvider + .buildConversationImagesUri(CONVERSATION_ID) + .toString(), + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelBindingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelBindingTest.kt new file mode 100644 index 000000000..7f193f35b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelBindingTest.kt @@ -0,0 +1,129 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import androidx.lifecycle.ViewModelStore +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelBindingTest : BaseConversationViewModelTest() { + + @Test + fun init_bindsAllDelegates() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val audioRecordingDelegate = createAudioRecordingDelegateMock() + val composerAttachmentsDelegate = createComposerAttachmentsDelegateMock() + val messagesDelegate = createMessagesDelegateMock() + val messageSelectionDelegate = createMessageSelectionDelegateMock() + val mediaPickerDelegate = createMediaPickerDelegateMock() + val metadataDelegate = createMetadataDelegateMock() + val focusDelegate = createFocusDelegateMock() + val viewModel = createViewModel( + audioRecordingDelegate = audioRecordingDelegate.mock, + composerAttachmentsDelegate = composerAttachmentsDelegate.mock, + draftDelegate = draftDelegate.mock, + messagesDelegate = messagesDelegate.mock, + messageSelectionDelegate = messageSelectionDelegate.mock, + mediaPickerDelegate = mediaPickerDelegate.mock, + metadataDelegate = metadataDelegate.mock, + focusDelegate = focusDelegate.mock, + ) + + advanceUntilIdle() + + assertEquals(1, draftDelegate.bindCalls.size) + assertEquals(1, audioRecordingDelegate.bindCalls.size) + assertEquals(1, composerAttachmentsDelegate.bindCalls.size) + assertEquals(1, messagesDelegate.bindCalls.size) + assertEquals(1, messageSelectionDelegate.bindCalls.size) + assertEquals(1, mediaPickerDelegate.bindCalls.size) + assertEquals(1, metadataDelegate.bindCalls.size) + assertEquals(1, focusDelegate.bindCalls.size) + assertSame( + draftDelegate.bindCalls.single().conversationIdFlow, + focusDelegate.bindCalls.single().conversationIdFlow, + ) + assertSame( + draftDelegate.stateFlow, + composerAttachmentsDelegate.bindCalls.single().draftStateFlow, + ) + assertSame( + draftDelegate.bindCalls.single().conversationIdFlow, + audioRecordingDelegate.bindCalls.single().conversationIdFlow, + ) + assertSame( + draftDelegate.bindCalls.single().conversationIdFlow, + messagesDelegate.bindCalls.single().conversationIdFlow, + ) + assertSame( + draftDelegate.bindCalls.single().conversationIdFlow, + messageSelectionDelegate.bindCalls.single().conversationIdFlow, + ) + assertSame( + draftDelegate.bindCalls.single().conversationIdFlow, + mediaPickerDelegate.bindCalls.single().conversationIdFlow, + ) + assertSame( + draftDelegate.bindCalls.single().conversationIdFlow, + metadataDelegate.bindCalls.single().conversationIdFlow, + ) + assertEquals(null, draftDelegate.bindCalls.single().conversationIdFlow.value) + + viewModel.onConversationIdChanged(conversationId = CONVERSATION_ID) + + assertEquals( + CONVERSATION_ID, + draftDelegate.bindCalls.single().conversationIdFlow.value, + ) + verify(exactly = 1) { + messageSelectionDelegate.mock.dismissMessageSelection() + } + } + } + + @Test + fun onCleared_flushesDraftDelegateAndMediaPickerDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val audioRecordingDelegate = createAudioRecordingDelegateMock() + val mediaPickerDelegate = createMediaPickerDelegateMock() + val focusDelegate = createFocusDelegateMock() + val viewModelStore = ViewModelStore() + createViewModelInStore( + viewModelStore = viewModelStore, + viewModelFactory = { + createViewModel( + audioRecordingDelegate = audioRecordingDelegate.mock, + draftDelegate = draftDelegate.mock, + mediaPickerDelegate = mediaPickerDelegate.mock, + focusDelegate = focusDelegate.mock, + ) + }, + ) + + viewModelStore.clear() + + verify(exactly = 1) { + audioRecordingDelegate.mock.onScreenCleared() + } + verify(exactly = 1) { + draftDelegate.mock.flushDraft() + } + verify(exactly = 1) { + mediaPickerDelegate.mock.onScreenCleared() + } + verify(exactly = 1) { + focusDelegate.mock.setScreenFocused(focused = false) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelCallActionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelCallActionTest.kt new file mode 100644 index 000000000..c7bce365e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelCallActionTest.kt @@ -0,0 +1,194 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import app.cash.turbine.test +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.ConversationViewModel +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelCallActionTest : BaseConversationViewModelTest() { + + @Test + fun scaffoldUiState_allowsCallForVoiceCapableOneOnOneNonEmergencyConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createCallActionViewModel( + metadataState = createOneOnOneMetadataState(), + isDeviceVoiceCapable = true, + ) + + viewModel.scaffoldUiState.test { + assertEquals(true, awaitItem().canCall) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun scaffoldUiState_hidesCallForNonVoiceCapableDevice() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createCallActionViewModel( + metadataState = createOneOnOneMetadataState(), + isDeviceVoiceCapable = false, + ) + + viewModel.scaffoldUiState.test { + assertFalse(awaitItem().canCall) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun scaffoldUiState_hidesCallForEmergencyConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createCallActionViewModel( + metadataState = createOneOnOneMetadataState( + phoneNumber = EMERGENCY_PHONE_NUMBER, + ), + isDeviceVoiceCapable = true, + ) + + viewModel.scaffoldUiState.test { + assertFalse(awaitItem().canCall) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun scaffoldUiState_checksEmergencyPhoneNumberBeforeEnablingCallAction() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + val isEmergencyPhoneNumber = mockk() + every { + isEmergencyPhoneNumber.invoke(phoneNumber = TEST_CALL_ACTION_PHONE_NUMBER) + } returns false + val viewModel = createViewModel( + metadataDelegate = metadataDelegate.mock, + isDeviceVoiceCapable = IsDeviceVoiceCapable { true }, + isEmergencyPhoneNumber = isEmergencyPhoneNumber, + ) + + metadataDelegate.stateFlow.value = createOneOnOneMetadataState() + + viewModel.scaffoldUiState.test { + assertEquals(false, awaitItem().canCall) + advanceUntilIdle() + assertEquals(true, awaitItem().canCall) + cancelAndIgnoreRemainingEvents() + } + + verify(atLeast = 1) { + isEmergencyPhoneNumber.invoke(phoneNumber = TEST_CALL_ACTION_PHONE_NUMBER) + } + } + } + + @Test + fun onCallClick_doesNotEmitCallEffectForEmergencyConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createCallActionViewModel( + metadataState = createOneOnOneMetadataState( + phoneNumber = EMERGENCY_PHONE_NUMBER, + ), + isDeviceVoiceCapable = true, + ) + + viewModel.effects.test { + viewModel.onCallClick() + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onCallClick_emitsCallEffectForNonEmergencyConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createCallActionViewModel( + metadataState = createOneOnOneMetadataState(), + isDeviceVoiceCapable = true, + ) + + viewModel.effects.test { + viewModel.onCallClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.PlacePhoneCall( + phoneNumber = TEST_CALL_ACTION_PHONE_NUMBER, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onCallClick_checksEmergencyPhoneNumberBeforeEmittingCallEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + metadataDelegate.stateFlow.value = createOneOnOneMetadataState() + val isEmergencyPhoneNumber = mockk() + every { + isEmergencyPhoneNumber.invoke(phoneNumber = TEST_CALL_ACTION_PHONE_NUMBER) + } returns false + val viewModel = createViewModel( + metadataDelegate = metadataDelegate.mock, + isEmergencyPhoneNumber = isEmergencyPhoneNumber, + ) + + viewModel.effects.test { + viewModel.onCallClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.PlacePhoneCall( + phoneNumber = TEST_CALL_ACTION_PHONE_NUMBER, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + + verify(atLeast = 1) { + isEmergencyPhoneNumber.invoke(phoneNumber = TEST_CALL_ACTION_PHONE_NUMBER) + } + } + } + + private fun createCallActionViewModel( + metadataState: ConversationMetadataUiState, + isDeviceVoiceCapable: Boolean, + ): ConversationViewModel { + val metadataDelegate = createMetadataDelegateMock() + metadataDelegate.stateFlow.value = metadataState + return createViewModel( + metadataDelegate = metadataDelegate.mock, + isDeviceVoiceCapable = IsDeviceVoiceCapable { + isDeviceVoiceCapable + }, + isEmergencyPhoneNumber = IsEmergencyPhoneNumber { phoneNumber -> + phoneNumber == EMERGENCY_PHONE_NUMBER + }, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDefaultSmsRoleTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDefaultSmsRoleTest.kt new file mode 100644 index 000000000..14839d065 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDefaultSmsRoleTest.kt @@ -0,0 +1,201 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import android.app.Activity +import android.content.Intent +import app.cash.turbine.test +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelDefaultSmsRoleTest : BaseConversationViewModelTest() { + + @Test + fun onDefaultSmsRolePromptActionClick_emitsRoleRequestLaunchEffectWhenIntentIsAvailable() { + runTest(context = mainDispatcherRule.testDispatcher) { + val requestIntent = Intent("request-sms-role") + val viewModel = createViewModel( + createDefaultSmsRoleRequest = CreateDefaultSmsRoleRequest { + requestIntent + }, + ) + + viewModel.effects.test { + viewModel.onDefaultSmsRolePromptActionClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.LaunchDefaultSmsRoleRequest( + intent = requestIntent, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onDefaultSmsRolePromptActionClick_emitsErrorMessageWhenIntentIsUnavailable() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel( + createDefaultSmsRoleRequest = CreateDefaultSmsRoleRequest { + null + }, + ) + + viewModel.effects.test { + viewModel.onDefaultSmsRolePromptActionClick() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.activity_not_found_message, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_isHandledByDraftDelegateFirst() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val messageSelectionDelegate = createMessageSelectionDelegateMock() + every { + draftDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } returns true + val viewModel = createViewModel( + draftDelegate = draftDelegate.mock, + messageSelectionDelegate = messageSelectionDelegate.mock, + ) + + viewModel.effects.test { + viewModel.onDefaultSmsRoleRequestResult(resultCode = Activity.RESULT_OK) + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + draftDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } + verify(exactly = 0) { + messageSelectionDelegate.mock.onDefaultSmsRoleRequestResult(any()) + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_fallsBackToMessageSelectionDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val messageSelectionDelegate = createMessageSelectionDelegateMock() + every { + draftDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } returns false + every { + messageSelectionDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } returns true + val viewModel = createViewModel( + draftDelegate = draftDelegate.mock, + messageSelectionDelegate = messageSelectionDelegate.mock, + ) + + viewModel.effects.test { + viewModel.onDefaultSmsRoleRequestResult(resultCode = Activity.RESULT_OK) + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + verify(exactly = 1) { + draftDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } + verify(exactly = 1) { + messageSelectionDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } + } + } + + @Test + fun onDefaultSmsRoleRequestResult_emitsSuccessToastForUnhandledResultOk() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val messageSelectionDelegate = createMessageSelectionDelegateMock() + every { + draftDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } returns false + every { + messageSelectionDelegate.mock.onDefaultSmsRoleRequestResult( + resultCode = Activity.RESULT_OK, + ) + } returns false + val viewModel = createViewModel( + draftDelegate = draftDelegate.mock, + messageSelectionDelegate = messageSelectionDelegate.mock, + ) + + viewModel.effects.test { + viewModel.onDefaultSmsRoleRequestResult(resultCode = Activity.RESULT_OK) + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.toast_after_setting_default_sms_app, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onDefaultSmsRoleRequestLaunchFailed_emitsActivityNotFoundMessage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + + viewModel.effects.test { + viewModel.onDefaultSmsRoleRequestLaunchFailed() + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.activity_not_found_message, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDelegationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDelegationTest.kt new file mode 100644 index 000000000..afba028df --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelDelegationTest.kt @@ -0,0 +1,193 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelDelegationTest : BaseConversationViewModelTest() { + + @Test + fun onSeedDraft_forwardsToDraftDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val viewModel = createViewModel( + draftDelegate = draftDelegate.mock, + ) + val draft = ConversationDraft( + messageText = "Hello", + selfParticipantId = "self-1", + ) + + viewModel.onSeedDraft( + conversationId = CONVERSATION_ID, + draft = draft, + ) + + verify(exactly = 1) { + draftDelegate.mock.seedDraft( + conversationId = CONVERSATION_ID, + draft = draft, + ) + } + } + } + + @Test + fun eventMethods_forwardToDelegates() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val audioRecordingDelegate = createAudioRecordingDelegateMock() + val messageSelectionDelegate = createMessageSelectionDelegateMock() + val mediaPickerDelegate = createMediaPickerDelegateMock() + draftDelegate.stateFlow.value = draftDelegate.stateFlow.value.copy( + draft = ConversationDraft( + selfParticipantId = AUDIO_RECORDING_SELF_PARTICIPANT_ID, + ), + ) + val viewModel = createViewModel( + audioRecordingDelegate = audioRecordingDelegate.mock, + draftDelegate = draftDelegate.mock, + messageSelectionDelegate = messageSelectionDelegate.mock, + mediaPickerDelegate = mediaPickerDelegate.mock, + ) + + viewModel.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Delete, + ) + viewModel.onMessageTextChanged(text = "Hello") + viewModel.onAudioRecordingStart() + viewModel.onLockedAudioRecordingStart() + viewModel.onAudioRecordingFinish() + viewModel.onAudioRecordingCancel() + viewModel.onPhotoPickerMediaSelected(contentUris = listOf("content://picker/1")) + viewModel.onPhotoPickerMediaDeselected(contentUris = listOf("content://picker/2")) + viewModel.onSendClick() + viewModel.dismissDeleteMessageConfirmation() + viewModel.dismissMessageSelection() + viewModel.confirmDeleteSelectedMessages() + viewModel.persistDraft() + + verify(exactly = 1) { + messageSelectionDelegate.mock.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Delete, + ) + } + verify(exactly = 1) { + draftDelegate.mock.onMessageTextChanged(messageText = "Hello") + } + verify(exactly = 1) { + audioRecordingDelegate.mock.startRecording( + selfParticipantId = AUDIO_RECORDING_SELF_PARTICIPANT_ID, + ) + } + verify(exactly = 1) { + audioRecordingDelegate.mock.startLockedRecording( + selfParticipantId = AUDIO_RECORDING_SELF_PARTICIPANT_ID, + ) + } + verify(exactly = 1) { + audioRecordingDelegate.mock.finishRecording() + } + verify(exactly = 1) { + audioRecordingDelegate.mock.cancelRecording() + } + verify(exactly = 1) { + mediaPickerDelegate.mock.onPhotoPickerMediaSelected( + contentUris = listOf("content://picker/1"), + ) + } + verify(exactly = 1) { + mediaPickerDelegate.mock.onPhotoPickerMediaDeselected( + contentUris = listOf("content://picker/2"), + ) + } + verify(exactly = 1) { + draftDelegate.mock.onSendClick() + } + verify(exactly = 1) { + messageSelectionDelegate.mock.dismissDeleteMessageConfirmation() + } + verify(exactly = 1) { + messageSelectionDelegate.mock.dismissMessageSelection() + } + verify(exactly = 1) { + messageSelectionDelegate.mock.confirmDeleteSelectedMessages() + } + verify(exactly = 1) { + draftDelegate.mock.persistDraft() + } + } + } + + @Test + fun conversationActionMethods_forwardToMetadataDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + val viewModel = createViewModel(metadataDelegate = metadataDelegate.mock) + + viewModel.onArchiveConversationClick() + viewModel.onUnarchiveConversationClick() + viewModel.onAddContactClick() + viewModel.onDeleteConversationClick() + viewModel.confirmDeleteConversation() + viewModel.dismissDeleteConversationConfirmation() + + verify(exactly = 1) { metadataDelegate.mock.onArchiveConversationClick() } + verify(exactly = 1) { metadataDelegate.mock.onUnarchiveConversationClick() } + verify(exactly = 1) { metadataDelegate.mock.onAddContactClick() } + verify(exactly = 1) { metadataDelegate.mock.onDeleteConversationClick() } + verify(exactly = 1) { metadataDelegate.mock.confirmDeleteConversation() } + verify(exactly = 1) { metadataDelegate.mock.dismissDeleteConversationConfirmation() } + } + } + + @Test + fun onScreenForegrounded_forwardsCancelNotificationFlagToFocusDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val focusDelegate = createFocusDelegateMock() + val viewModel = createViewModel(focusDelegate = focusDelegate.mock) + + viewModel.onScreenForegrounded(cancelNotification = true) + viewModel.onScreenForegrounded(cancelNotification = false) + + verify(exactly = 1) { + focusDelegate.mock.setScreenFocused( + focused = true, + cancelNotification = true, + ) + } + verify(exactly = 1) { + focusDelegate.mock.setScreenFocused( + focused = true, + cancelNotification = false, + ) + } + } + } + + @Test + fun onScreenBackgrounded_unsetsFocusOnDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val focusDelegate = createFocusDelegateMock() + val viewModel = createViewModel(focusDelegate = focusDelegate.mock) + + viewModel.onScreenBackgrounded() + + verify(exactly = 1) { + focusDelegate.mock.setScreenFocused(focused = false) + } + } + } + + private companion object { + private const val AUDIO_RECORDING_SELF_PARTICIPANT_ID = "self-recording" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelEffectRelayTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelEffectRelayTest.kt new file mode 100644 index 000000000..5c96702a7 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelEffectRelayTest.kt @@ -0,0 +1,115 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import app.cash.turbine.test +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelEffectRelayTest : BaseConversationViewModelTest() { + + @Test + fun mediaPickerEffects_areExposedAsScreenEffects() { + runTest(context = mainDispatcherRule.testDispatcher) { + val mediaPickerDelegate = createMediaPickerDelegateMock() + val viewModel = createViewModel( + mediaPickerDelegate = mediaPickerDelegate.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + mediaPickerDelegate.effectsFlow.emit( + ConversationScreenEffect.ShowMessage( + messageResId = 123, + ), + ) + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = 123, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun messageSelectionEffects_areExposedAsScreenEffects() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messageSelectionDelegate = createMessageSelectionDelegateMock() + val viewModel = createViewModel( + messageSelectionDelegate = messageSelectionDelegate.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + messageSelectionDelegate.effectsFlow.emit( + ConversationScreenEffect.ShowMessage( + messageResId = 456, + ), + ) + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = 456, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun draftEffects_areExposedAsScreenEffects() { + runTest(context = mainDispatcherRule.testDispatcher) { + val draftDelegate = createDraftDelegateMock() + val viewModel = createViewModel( + draftDelegate = draftDelegate.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + draftDelegate.effectsFlow.emit( + ConversationScreenEffect.ShowMessage( + messageResId = 789, + ), + ) + + assertEquals( + ConversationScreenEffect.ShowMessage( + messageResId = 789, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun metadataEffects_areExposedAsScreenEffects() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + val viewModel = createViewModel( + metadataDelegate = metadataDelegate.mock, + ) + advanceUntilIdle() + + viewModel.effects.test { + metadataDelegate.effectsFlow.emit(ConversationScreenEffect.CloseConversation) + + assertEquals(ConversationScreenEffect.CloseConversation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelMessageInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelMessageInteractionTest.kt new file mode 100644 index 000000000..34cd541e4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelMessageInteractionTest.kt @@ -0,0 +1,100 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import app.cash.turbine.test +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationViewModelMessageInteractionTest : BaseConversationViewModelTest() { + + @Test + fun messageTapMethods_forwardToMessageSelectionDelegate() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messageSelectionDelegate = createMessageSelectionDelegateMock() + val viewModel = createViewModel( + messageSelectionDelegate = messageSelectionDelegate.mock, + ) + + viewModel.onMessageClick(messageId = "message-1") + viewModel.onMessageLongClick(messageId = "message-2") + viewModel.onMessageResendClick(messageId = "message-3") + + verify(exactly = 1) { + messageSelectionDelegate.mock.onMessageClick(messageId = "message-1") + } + verify(exactly = 1) { + messageSelectionDelegate.mock.onMessageLongClick(messageId = "message-2") + } + verify(exactly = 1) { + messageSelectionDelegate.mock.onMessageResendClick(messageId = "message-3") + } + } + } + + @Test + fun onMessageAvatarClick_whenMessageCanShowContactCard_emitsShowOrAddParticipantContact() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messagesDelegate = createMessagesDelegateMock() + messagesDelegate.stateFlow.value = ConversationMessagesUiState.Present( + messages = persistentListOf( + createMessageUiModel().copy( + messageId = "message-1", + senderContactId = 42L, + senderContactLookupKey = "lookup-key", + senderNormalizedDestination = "+15551234567", + ), + ), + ) + val viewModel = createViewModel(messagesDelegate = messagesDelegate.mock) + + viewModel.effects.test { + viewModel.onMessageAvatarClick(messageId = "message-1") + advanceUntilIdle() + + assertEquals( + ConversationScreenEffect.ShowOrAddParticipantContact( + contactId = 42L, + contactLookupKey = "lookup-key", + avatarUri = null, + normalizedDestination = "+15551234567", + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun onMessageAvatarClick_whenMessageCannotShowContactCard_emitsNoEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messagesDelegate = createMessagesDelegateMock() + messagesDelegate.stateFlow.value = ConversationMessagesUiState.Present( + messages = persistentListOf( + createMessageUiModel().copy( + messageId = "message-1", + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderNormalizedDestination = null, + ), + ), + ) + val viewModel = createViewModel(messagesDelegate = messagesDelegate.mock) + + viewModel.effects.test { + viewModel.onMessageAvatarClick(messageId = "message-1") + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelUiStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelUiStateTest.kt new file mode 100644 index 000000000..632ed6108 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelUiStateTest.kt @@ -0,0 +1,242 @@ +package com.android.messaging.ui.conversation.screen.viewmodel + +import app.cash.turbine.test +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationViewModelUiStateTest : BaseConversationViewModelTest() { + + @Test + fun scaffoldUiState_combinesDelegateStatesUsingComposerMapper() { + runTest(context = mainDispatcherRule.testDispatcher) { + val composerUiState = ConversationComposerUiState( + messageText = "Mapped text", + isSendEnabled = true, + ) + val draftDelegate = createDraftDelegateMock() + val messagesDelegate = createMessagesDelegateMock() + val messageSelectionDelegate = createMessageSelectionDelegateMock() + val metadataDelegate = createMetadataDelegateMock() + val viewModel = createViewModel( + draftDelegate = draftDelegate.mock, + messagesDelegate = messagesDelegate.mock, + messageSelectionDelegate = messageSelectionDelegate.mock, + metadataDelegate = metadataDelegate.mock, + composerUiStateMapper = createComposerUiStateMapperMock( + mappedUiState = composerUiState, + ), + ) + + val metadataState = ConversationMetadataUiState.Present( + title = "Weekend plan", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ), + participantCount = 2, + otherParticipantDisplayDestination = null, + otherParticipantPhoneNumber = null, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + val messagesState = ConversationMessagesUiState.Present( + messages = listOf( + createMessageUiModel(), + ).toPersistentList(), + ) + val selectionState = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf(MESSAGE_ID), + ) + metadataDelegate.stateFlow.value = metadataState + messagesDelegate.stateFlow.value = messagesState + messageSelectionDelegate.stateFlow.value = selectionState + draftDelegate.stateFlow.value = ConversationDraftState( + draft = ConversationDraft( + messageText = "Draft text", + ), + ) + + viewModel.scaffoldUiState.test { + assertEquals( + ConversationScreenScaffoldUiState( + composer = composerUiState, + ), + awaitItem(), + ) + advanceUntilIdle() + + assertEquals( + ConversationScreenScaffoldUiState( + canAddPeople = false, + canArchive = true, + canDeleteConversation = true, + canEditSubject = true, + metadata = metadataState, + messages = messagesState, + composer = composerUiState, + selection = selectionState, + ), + expectMostRecentItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun mediaPickerOverlayUiState_combinesComposerMetadataAndPhotoPickerSourceUris() { + runTest(context = mainDispatcherRule.testDispatcher) { + val photoPickerSourceUris = persistentMapOf( + "content://scratch/1" to "content://picker/1", + ) + val composerUiState = ConversationComposerUiState( + isSendEnabled = true, + ) + val mediaPickerDelegate = createMediaPickerDelegateMock() + val metadataDelegate = createMetadataDelegateMock() + val viewModel = createViewModel( + mediaPickerDelegate = mediaPickerDelegate.mock, + metadataDelegate = metadataDelegate.mock, + composerUiStateMapper = createComposerUiStateMapperMock( + mappedUiState = composerUiState, + ), + ) + + viewModel.mediaPickerOverlayUiState.test { + awaitItem() + + mediaPickerDelegate.photoPickerSourceContentUriByAttachmentContentUriFlow.value = + photoPickerSourceUris + awaitItem() + + metadataDelegate.stateFlow.value = ConversationMetadataUiState.Present( + title = "Weekend plan", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ), + participantCount = 2, + otherParticipantDisplayDestination = null, + otherParticipantPhoneNumber = null, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + + val overlayState = awaitItem() + + assertEquals("Weekend plan", overlayState.conversationTitle) + assertEquals( + photoPickerSourceUris, + overlayState.photoPickerSourceContentUriByAttachmentContentUri, + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun scaffoldUiState_enablesAddPeopleWhenConversationIsBelowRecipientLimit() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + val canAddMoreConversationParticipants = mockk() + every { + canAddMoreConversationParticipants.invoke(participantCount = 2) + } returns true + val viewModel = createViewModel( + metadataDelegate = metadataDelegate.mock, + canAddMoreConversationParticipants = canAddMoreConversationParticipants, + ) + + metadataDelegate.stateFlow.value = ConversationMetadataUiState.Present( + title = "Weekend plan", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Group, + participantCount = 2, + otherParticipantDisplayDestination = null, + otherParticipantPhoneNumber = null, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + viewModel.scaffoldUiState.test { + assertEquals(false, awaitItem().canAddPeople) + advanceUntilIdle() + assertEquals(true, awaitItem().canAddPeople) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun scaffoldUiState_disablesAddPeopleWhenConversationReachedRecipientLimit() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + val canAddMoreConversationParticipants = mockk() + every { + canAddMoreConversationParticipants.invoke(participantCount = 10) + } returns false + val viewModel = createViewModel( + metadataDelegate = metadataDelegate.mock, + canAddMoreConversationParticipants = canAddMoreConversationParticipants, + ) + + metadataDelegate.stateFlow.value = ConversationMetadataUiState.Present( + title = "Weekend plan", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Group, + participantCount = 10, + otherParticipantDisplayDestination = null, + otherParticipantPhoneNumber = null, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + viewModel.scaffoldUiState.test { + assertEquals(false, awaitItem().canAddPeople) + advanceUntilIdle() + assertEquals(false, awaitItem().canAddPeople) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun scaffoldUiState_reflectsMetadataDeleteConfirmationVisibility() { + runTest(context = mainDispatcherRule.testDispatcher) { + val metadataDelegate = createMetadataDelegateMock() + val viewModel = createViewModel(metadataDelegate = metadataDelegate.mock) + + viewModel.scaffoldUiState.test { + assertEquals(false, awaitItem().isDeleteConversationConfirmationVisible) + + metadataDelegate.deleteConfirmationVisibleFlow.value = true + advanceUntilIdle() + assertEquals(true, awaitItem().isDeleteConversationConfirmationVisible) + cancelAndIgnoreRemainingEvents() + } + } + } +} From 975718377fb3f4c0cbc7542e74d58c3bdce1c76f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 13:19:29 +0300 Subject: [PATCH 11/38] Remove dead code --- .../mapper/ConversationDraftAttachmentMapper.kt | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt b/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt index 5d6a6be2c..5728c6462 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt @@ -2,27 +2,15 @@ package com.android.messaging.ui.conversation.mediapicker.mapper import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.media.model.ConversationCapturedMedia -import com.android.messaging.data.media.model.ConversationMediaItem import javax.inject.Inject internal interface ConversationDraftAttachmentMapper { - fun map(mediaItem: ConversationMediaItem): ConversationDraftAttachment - fun map(capturedMedia: ConversationCapturedMedia): ConversationDraftAttachment } internal class ConversationDraftAttachmentMapperImpl @Inject constructor() : ConversationDraftAttachmentMapper { - override fun map(mediaItem: ConversationMediaItem): ConversationDraftAttachment { - return ConversationDraftAttachment( - contentType = mediaItem.contentType, - contentUri = mediaItem.contentUri, - width = mediaItem.width, - height = mediaItem.height, - ) - } - override fun map(capturedMedia: ConversationCapturedMedia): ConversationDraftAttachment { return ConversationDraftAttachment( contentType = capturedMedia.contentType, From 0befae0991e086c49f455b64b59863834f9928e1 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 31 May 2026 13:19:55 +0300 Subject: [PATCH 12/38] Add test tags to the UI elements --- .../ui/conversation/ConversationTestTags.kt | 21 +++++++++++++++++++ .../ui/conversation/entry/NewChatScreen.kt | 11 +++++++++- .../ui/conversation/entry/NewGroupButton.kt | 5 ++++- .../ui/message/ConversationMessageRows.kt | 13 ++++++++++++ .../screen/ConversationScreenDialogs.kt | 8 +++++++ .../screen/ConversationSelectionTopAppBar.kt | 16 ++++++++++++++ 6 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index 594f12fbc..bba7447b7 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -26,6 +26,12 @@ internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_ internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" internal const val CONVERSATION_MMS_INDICATOR_TEST_TAG = "conversation_mms_indicator" internal const val CONVERSATION_SEGMENT_COUNTER_TEST_TAG = "conversation_segment_counter" +internal const val CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG = + "conversation_selection_overflow_button" +internal const val CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG = + "conversation_delete_messages_confirm_button" +internal const val CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG = + "conversation_delete_messages_dismiss_button" internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG = "conversation_inline_audio_attachment_play_button" internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG = @@ -36,11 +42,14 @@ internal const val CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG = internal const val CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG = "conversation_audio_recording_lock_affordance" internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = "add_participants_confirm_button" +internal const val NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG = "new_chat_create_group_button" internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = "new_chat_create_group_next_button" internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = "new_chat_contact_resolving_indicator" +internal const val NEW_CHAT_NAVIGATE_BACK_BUTTON_TEST_TAG = "new_chat_navigate_back_button" internal const val NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG = "new_chat_sim_selector_chip" internal const val NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG = "new_chat_sim_selector_dropdown" +internal const val NEW_CHAT_TOP_APP_BAR_TITLE_TEST_TAG = "new_chat_top_app_bar_title" internal const val RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG = "recipient_selection_query_field" internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" internal const val CONVERSATION_SEND_BUTTON_TEST_TAG = "conversation_send_button" @@ -72,6 +81,18 @@ internal fun conversationMessageItemTestTag(messageId: String): String { return "conversation_message_item_$messageId" } +internal fun conversationMessageBubbleTestTag(messageId: String): String { + return "conversation_message_bubble_$messageId" +} + +internal fun conversationMessageSelectionRowTestTag(messageId: String): String { + return "conversation_message_selection_row_$messageId" +} + +internal fun conversationMessageSelectionActionButtonTestTag(action: String): String { + return "conversation_message_selection_action_${action.lowercase()}" +} + internal fun conversationAttachmentPreviewItemTestTag(attachmentKey: String): String { return "conversation_attachment_preview_item_$attachmentKey" } diff --git a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt index 1e9cca08a..a0ed72ea8 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -31,6 +32,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.ui.conversation.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_NAVIGATE_BACK_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_TOP_APP_BAR_TITLE_TEST_TAG import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState import com.android.messaging.ui.conversation.entry.model.NewChatEffect import com.android.messaging.ui.conversation.entry.model.NewChatUiState @@ -204,6 +207,8 @@ private fun NewChatTopAppBar( ), navigationIcon = { IconButton( + modifier = Modifier + .testTag(tag = NEW_CHAT_NAVIGATE_BACK_BUTTON_TEST_TAG), onClick = onNavigateBack, ) { Icon( @@ -213,7 +218,11 @@ private fun NewChatTopAppBar( } }, title = { - Text(text = newChatTitle(isCreatingGroup = isCreatingGroup)) + Text( + modifier = Modifier + .testTag(tag = NEW_CHAT_TOP_APP_BAR_TITLE_TEST_TAG), + text = newChatTitle(isCreatingGroup = isCreatingGroup), + ) }, ) } diff --git a/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt b/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt index a94d47005..12feb4f93 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt @@ -31,10 +31,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG import com.android.messaging.ui.core.MessagingPreviewColumn @Composable @@ -67,7 +69,8 @@ private fun NewGroupButton( val hapticFeedback = LocalHapticFeedback.current FilledTonalButton( - modifier = modifier, + modifier = modifier + .testTag(tag = NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG), onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) onClick() diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index bbf6b517d..2fc3388d6 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected @@ -24,6 +25,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag +import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status import com.android.messaging.ui.conversation.preview.previewAudioPart @@ -112,6 +115,11 @@ private fun ConversationMessageBubbleRowContainer( Row( modifier = Modifier .fillMaxWidth() + .testTag( + tag = conversationMessageSelectionRowTestTag( + messageId = message.messageId, + ), + ) .conversationMessageSelectionModeRowModifier( isSelected = isSelected, isSelectionMode = isSelectionMode, @@ -236,6 +244,11 @@ private fun Modifier.conversationMessageBubbleInteractionModifier( ): Modifier { val hapticFeedback = LocalHapticFeedback.current val bubbleModifier = this + .testTag( + tag = conversationMessageBubbleTestTag( + messageId = message.messageId, + ), + ) .clip(shape = layout.bubbleShape) return when { diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt index c06814ea1..301d63830 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG @@ -268,6 +270,9 @@ private fun ConversationDeleteMessagesDialog( }, confirmButton = { TextButton( + modifier = Modifier.testTag( + tag = CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG, + ), onClick = onConfirm, ) { Text( @@ -277,6 +282,9 @@ private fun ConversationDeleteMessagesDialog( }, dismissButton = { TextButton( + modifier = Modifier.testTag( + tag = CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG, + ), onClick = onDismiss, ) { Text( diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt index a49a3b681..6c08c6451 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt @@ -26,11 +26,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageSelectionActionButtonTestTag import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState import com.android.messaging.ui.core.MessagingPreviewTheme @@ -162,6 +166,10 @@ private fun ConversationSelectionActions( @Composable private fun ConversationSelectionOverflowButton(onClick: () -> Unit) { IconButton( + modifier = Modifier + .testTag( + tag = CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG, + ), onClick = onClick, ) { Icon( @@ -186,6 +194,11 @@ private fun ConversationSelectionOverflowMenu( ) { actions.forEach { action -> DropdownMenuItem( + modifier = Modifier.testTag( + tag = conversationMessageSelectionActionButtonTestTag( + action = action.name, + ), + ), text = { Text(text = selectionActionLabel(action = action)) }, @@ -220,6 +233,9 @@ private fun ConversationSelectionActionButton( onActionClick: (ConversationMessageSelectionAction) -> Unit, ) { IconButton( + modifier = Modifier.testTag( + tag = conversationMessageSelectionActionButtonTestTag(action = action.name), + ), onClick = { onActionClick(action) }, From b640402cb8de6a5a8858f947092b9a4a6c9ad57a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:11:42 +0300 Subject: [PATCH 13/38] Enable Compose Robolectric unit tests --- app/build.gradle.kts | 18 ++- .../testutil/ComposeTouchInputTestUtils.kt | 19 +++ .../messaging/testutil/TestLifecycleOwner.kt | 24 ++++ .../common/test/helpers/TestContext.kt | 9 ++ app/src/test/resources/robolectric.properties | 3 +- gradle/verification-metadata.xml | 109 ++++++++++++++++++ 6 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/testutil/ComposeTouchInputTestUtils.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/testutil/TestLifecycleOwner.kt create mode 100644 app/src/test/kotlin/com/android/common/test/helpers/TestContext.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 152ee16b2..b7c64ec75 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,10 @@ java { } } +val unitTestJavaLauncher = javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) +} + detekt { basePath.set(rootDir) buildUponDefaultConfig = true @@ -142,10 +146,14 @@ android { } testOptions { - unitTests.all { - it.extensions.configure(JacocoTaskExtension::class.java) { - isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") + unitTests { + isIncludeAndroidResources = true + all { unitTest -> + unitTest.javaLauncher.set(unitTestJavaLauncher) + unitTest.extensions.configure(JacocoTaskExtension::class.java) { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } } } } @@ -212,6 +220,8 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.junit4) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) diff --git a/app/src/sharedTest/kotlin/com/android/messaging/testutil/ComposeTouchInputTestUtils.kt b/app/src/sharedTest/kotlin/com/android/messaging/testutil/ComposeTouchInputTestUtils.kt new file mode 100644 index 000000000..e89ac4a94 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/testutil/ComposeTouchInputTestUtils.kt @@ -0,0 +1,19 @@ +package com.android.messaging.testutil + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.click +import androidx.compose.ui.test.performTouchInput + +internal fun SemanticsNodeInteraction.performDisabledTouchClick() { + performCenterTouchClick() +} + +internal fun SemanticsNodeInteraction.performTouchClick() { + performCenterTouchClick() +} + +private fun SemanticsNodeInteraction.performCenterTouchClick() { + performTouchInput { + click(position = center) + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/testutil/TestLifecycleOwner.kt b/app/src/sharedTest/kotlin/com/android/messaging/testutil/TestLifecycleOwner.kt new file mode 100644 index 000000000..fec5b5068 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/testutil/TestLifecycleOwner.kt @@ -0,0 +1,24 @@ +package com.android.messaging.testutil + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +internal class TestLifecycleOwner( + initialState: Lifecycle.State, +) : LifecycleOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + + init { + lifecycleRegistry.currentState = initialState + } + + override val lifecycle: Lifecycle + get() { + return lifecycleRegistry + } + + fun moveTo(state: Lifecycle.State) { + lifecycleRegistry.currentState = state + } +} diff --git a/app/src/test/kotlin/com/android/common/test/helpers/TestContext.kt b/app/src/test/kotlin/com/android/common/test/helpers/TestContext.kt new file mode 100644 index 000000000..156c0ac8a --- /dev/null +++ b/app/src/test/kotlin/com/android/common/test/helpers/TestContext.kt @@ -0,0 +1,9 @@ +package com.android.common.test.helpers + +import android.content.Context +import org.robolectric.RuntimeEnvironment + +internal val targetContext: Context + get() { + return RuntimeEnvironment.getApplication() + } diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties index 3f67ea5ac..16f02dc6a 100644 --- a/app/src/test/resources/robolectric.properties +++ b/app/src/test/resources/robolectric.properties @@ -1 +1,2 @@ -sdk=35 +sdk=36 +application=android.app.Application diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f0456cea3..f0b211f25 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -8777,5 +8777,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From dfb4556a2e2ee324b545765eb7f11e52f47e6c32 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:12:13 +0300 Subject: [PATCH 14/38] Move instrumentation helpers to the Kotlin source set --- .../common/test/helpers/ShellCommandHelper.kt | 24 --- .../common/test/helpers/TestDataSeeder.kt | 18 --- .../android/common/test/rules/AppTestRule.kt | 21 --- .../test/helpers/InstrumentationContext.kt | 9 ++ .../common/test/helpers/ShellCommandHelper.kt | 152 ++++++++++++++++++ .../android/common/test/rules/AppTestRule.kt | 41 +++++ .../common/test/rules/MessagingTestRule.kt | 8 +- 7 files changed, 207 insertions(+), 66 deletions(-) delete mode 100644 app/src/androidTest/java/com/android/common/test/helpers/ShellCommandHelper.kt delete mode 100644 app/src/androidTest/java/com/android/common/test/helpers/TestDataSeeder.kt delete mode 100644 app/src/androidTest/java/com/android/common/test/rules/AppTestRule.kt create mode 100644 app/src/androidTest/kotlin/com/android/common/test/helpers/InstrumentationContext.kt create mode 100644 app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt create mode 100644 app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt rename app/src/androidTest/{java => kotlin}/com/android/common/test/rules/MessagingTestRule.kt (66%) diff --git a/app/src/androidTest/java/com/android/common/test/helpers/ShellCommandHelper.kt b/app/src/androidTest/java/com/android/common/test/helpers/ShellCommandHelper.kt deleted file mode 100644 index b79697b6d..000000000 --- a/app/src/androidTest/java/com/android/common/test/helpers/ShellCommandHelper.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.android.common.test.helpers - -import android.os.ParcelFileDescriptor -import androidx.test.platform.app.InstrumentationRegistry - -object ShellCommandHelper { - - fun setupSmsDefaultRole() { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val packageName = instrumentation.targetContext.packageName - val command = "cmd role add-role-holder android.app.role.SMS $packageName" - - executeShellCommand(command) - } - - private fun executeShellCommand(command: String): String { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val parcelFileDescriptor = instrumentation.uiAutomation.executeShellCommand(command) - - return ParcelFileDescriptor.AutoCloseInputStream(parcelFileDescriptor).use { inputStream -> - String(inputStream.readBytes()) - } - } -} diff --git a/app/src/androidTest/java/com/android/common/test/helpers/TestDataSeeder.kt b/app/src/androidTest/java/com/android/common/test/helpers/TestDataSeeder.kt deleted file mode 100644 index 02d879246..000000000 --- a/app/src/androidTest/java/com/android/common/test/helpers/TestDataSeeder.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.android.common.test.helpers - -import androidx.test.platform.app.InstrumentationRegistry -import com.android.messaging.debug.clearSeededTestData -import com.android.messaging.debug.seedTestData - -object TestDataSeeder { - - fun seedTestData() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - seedTestData(context) - } - - fun clearSeededTestData() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - clearSeededTestData(context) - } -} diff --git a/app/src/androidTest/java/com/android/common/test/rules/AppTestRule.kt b/app/src/androidTest/java/com/android/common/test/rules/AppTestRule.kt deleted file mode 100644 index 8c0a26717..000000000 --- a/app/src/androidTest/java/com/android/common/test/rules/AppTestRule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.android.common.test.rules - -import com.android.common.test.helpers.ShellCommandHelper -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement - -class AppTestRule : TestRule { - - override fun apply( - base: Statement, - description: Description, - ): Statement { - return object : Statement() { - override fun evaluate() { - ShellCommandHelper.setupSmsDefaultRole() - base.evaluate() - } - } - } -} diff --git a/app/src/androidTest/kotlin/com/android/common/test/helpers/InstrumentationContext.kt b/app/src/androidTest/kotlin/com/android/common/test/helpers/InstrumentationContext.kt new file mode 100644 index 000000000..728c7eee9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/common/test/helpers/InstrumentationContext.kt @@ -0,0 +1,9 @@ +package com.android.common.test.helpers + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry + +internal val targetContext: Context + get() { + return InstrumentationRegistry.getInstrumentation().targetContext + } diff --git a/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt b/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt new file mode 100644 index 000000000..d0c531f64 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt @@ -0,0 +1,152 @@ +package com.android.common.test.helpers + +import android.os.ParcelFileDescriptor +import androidx.test.platform.app.InstrumentationRegistry + +object ShellCommandHelper { + + private var originalSmsRoleHolders: List? = null + private var smsRoleRestoreGeneration = 0 + + fun setupSmsDefaultRole(): List { + val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName + cancelScheduledSmsDefaultRoleRestore(packageName = packageName) + + val currentRoleHolders = getSmsRoleHolders() + val previousRoleHolders = originalSmsRoleHolders ?: currentRoleHolders.also { roleHolders -> + originalSmsRoleHolders = roleHolders + } + if (packageName !in currentRoleHolders) { + executeCheckedShellCommand( + command = "cmd role add-role-holder $SMS_ROLE_NAME $packageName", + failureMessage = "Failed to set SMS default role holder for $packageName", + ) + } + + return previousRoleHolders + } + + fun restoreSmsDefaultRole(previousRoleHolders: List) { + val currentRoleHolders = getSmsRoleHolders() + if (currentRoleHolders == previousRoleHolders) { + return + } + + scheduleSmsDefaultRoleRestore(previousRoleHolders = previousRoleHolders) + } + + private fun cancelScheduledSmsDefaultRoleRestore(packageName: String) { + smsRoleRestoreGeneration += 1 + executeCheckedShellCommand( + command = "sh -c ${ + shellSingleQuoted( + value = "printf %s $smsRoleRestoreGeneration > ${ + smsRoleRestoreGenerationFilePath(packageName = packageName) + }", + ) + }", + failureMessage = "Failed to cancel pending SMS default role restore", + ) + } + + private fun scheduleSmsDefaultRoleRestore(previousRoleHolders: List) { + val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName + smsRoleRestoreGeneration += 1 + val generationFilePath = smsRoleRestoreGenerationFilePath(packageName = packageName) + val restoreCommand = smsRoleRestoreCommand( + previousRoleHolders = previousRoleHolders, + generation = smsRoleRestoreGeneration, + generationFilePath = generationFilePath, + ) + + executeCheckedShellCommand( + command = "sh -c ${ + shellSingleQuoted( + value = "printf %s $smsRoleRestoreGeneration > $generationFilePath", + ) + }", + failureMessage = "Failed to prepare SMS default role restore", + ) + executeCheckedShellCommand( + command = "sh -c ${shellSingleQuoted(value = restoreCommand)}", + failureMessage = "Failed to schedule SMS default role restore", + ) + } + + private fun smsRoleRestoreCommand( + previousRoleHolders: List, + generation: Int, + generationFilePath: String, + ): String { + val restoreRoleHoldersCommand = previousRoleHolders.joinToString( + separator = " " + ) { roleHolder -> + "cmd role add-role-holder $SMS_ROLE_NAME ${shellWord(value = roleHolder)};" + } + + return "{" + + " sleep $SMS_ROLE_RESTORE_DELAY_SECONDS;" + + " if [ \"\$(cat ${shellWord( + value = generationFilePath + )} 2>/dev/null)\" = \"$generation\" ]; then" + + " cmd role clear-role-holders $SMS_ROLE_NAME;" + + " $restoreRoleHoldersCommand" + + " rm -f ${shellWord(value = generationFilePath)};" + + " fi;" + + " } >/dev/null 2>&1 &" + } + + private fun smsRoleRestoreGenerationFilePath(packageName: String): String { + return "$SMS_ROLE_RESTORE_GENERATION_FILE_PREFIX$packageName" + } + + private fun getSmsRoleHolders(): List { + val result = executeCheckedShellCommand( + command = "cmd role get-role-holders $SMS_ROLE_NAME", + failureMessage = "Failed to read SMS default role holders", + ) + return result + .lineSequence() + .map { line -> line.trim() } + .filter { line -> line.isNotEmpty() } + .toList() + } + + private fun executeCheckedShellCommand( + command: String, + failureMessage: String, + ): String { + val result = executeShellCommand(command = command) + check(!result.indicatesShellError()) { + "$failureMessage: $result" + } + return result + } + + private fun executeShellCommand(command: String): String { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val parcelFileDescriptor = instrumentation.uiAutomation.executeShellCommand(command) + + return ParcelFileDescriptor.AutoCloseInputStream(parcelFileDescriptor).use { inputStream -> + String(inputStream.readBytes()) + } + } + + private fun String.indicatesShellError(): Boolean { + return contains(other = "Error", ignoreCase = true) || + contains(other = "Exception", ignoreCase = true) + } + + private fun shellSingleQuoted(value: String): String { + return "'${value.replace(oldValue = "'", newValue = "'\\''")}'" + } + + private fun shellWord(value: String): String { + return shellSingleQuoted(value = value) + } + + private const val SMS_ROLE_NAME = "android.app.role.SMS" + private const val SMS_ROLE_RESTORE_DELAY_SECONDS = 15 + private const val SMS_ROLE_RESTORE_GENERATION_FILE_PREFIX = + "/data/local/tmp/com.android.messaging.sms-role-restore." +} diff --git a/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt b/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt new file mode 100644 index 000000000..c52832f93 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt @@ -0,0 +1,41 @@ +package com.android.common.test.rules + +import com.android.common.test.helpers.ShellCommandHelper +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class AppTestRule : TestRule { + + override fun apply( + base: Statement, + description: Description, + ): Statement { + return object : Statement() { + override fun evaluate() { + val previousSmsRoleHolders = ShellCommandHelper.setupSmsDefaultRole() + var baseFailure: Throwable? = null + try { + base.evaluate() + } catch (throwable: Throwable) { + baseFailure = throwable + } finally { + try { + ShellCommandHelper.restoreSmsDefaultRole( + previousRoleHolders = previousSmsRoleHolders, + ) + } catch (restoreFailure: Throwable) { + if (baseFailure == null) { + throw restoreFailure + } + baseFailure.addSuppressed(restoreFailure) + } + } + + if (baseFailure != null) { + throw baseFailure + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/common/test/rules/MessagingTestRule.kt b/app/src/androidTest/kotlin/com/android/common/test/rules/MessagingTestRule.kt similarity index 66% rename from app/src/androidTest/java/com/android/common/test/rules/MessagingTestRule.kt rename to app/src/androidTest/kotlin/com/android/common/test/rules/MessagingTestRule.kt index 72f75708a..704382f0a 100644 --- a/app/src/androidTest/java/com/android/common/test/rules/MessagingTestRule.kt +++ b/app/src/androidTest/kotlin/com/android/common/test/rules/MessagingTestRule.kt @@ -1,6 +1,8 @@ package com.android.common.test.rules -import com.android.common.test.helpers.TestDataSeeder +import com.android.common.test.helpers.targetContext +import com.android.messaging.debug.clearSeededTestData +import com.android.messaging.debug.seedTestData import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement @@ -13,11 +15,11 @@ class MessagingTestRule : TestRule { ): Statement { return object : Statement() { override fun evaluate() { - TestDataSeeder.seedTestData() + seedTestData(targetContext) try { base.evaluate() } finally { - TestDataSeeder.clearSeededTestData() + clearSeededTestData(targetContext) } } } From 1da275c086005b317ed607391ee908b493581145 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:12:42 +0300 Subject: [PATCH 15/38] Refresh existing instrumentation tests --- .../general/ui/AppSettingsScreenTest.kt | 102 +++++------ .../appsettings/screen/SettingsScreenTest.kt | 134 +++++++-------- .../ui/SubscriptionSettingsScreenTest.kt | 85 +++++----- .../conversation/ConversationUserFlowTest.kt | 23 +-- .../ConversationMessageLinkLongClickTest.kt | 159 ++++++++++-------- .../ui/ConversationTopAppBarLayoutTest.kt} | 43 ++--- 6 files changed, 285 insertions(+), 261 deletions(-) rename app/src/androidTest/{java => kotlin}/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt (63%) rename app/src/androidTest/{java => kotlin}/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt (74%) rename app/src/androidTest/{java => kotlin}/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt (74%) rename app/src/androidTest/{java => kotlin}/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt (84%) rename app/src/androidTest/{java => kotlin}/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt (59%) rename app/src/androidTest/{java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt => kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarLayoutTest.kt} (54%) diff --git a/app/src/androidTest/java/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt similarity index 63% rename from app/src/androidTest/java/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt rename to app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt index 583d6eb0f..8aed2d137 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt @@ -1,10 +1,11 @@ package com.android.messaging.ui.appsettings.general.ui -import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.common.test.helpers.targetContext import com.android.messaging.R import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState import com.android.messaging.ui.appsettings.screen.SettingsScreenModel @@ -16,11 +17,13 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class AppSettingsScreenTest { @get:Rule - val composeTestRule = createAndroidComposeRule() + val composeTestRule = createComposeRule() private lateinit var screenModel: SettingsScreenModel @@ -50,11 +53,13 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) - val title = composeTestRule.activity.getString(R.string.sms_disabled_pref_title) + val title = targetContext.getString(R.string.sms_disabled_pref_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.DefaultSmsAppClicked(true)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.DefaultSmsAppClicked(true)) + } } } @@ -62,13 +67,15 @@ class AppSettingsScreenTest { fun notificationsClick_delegatesToScreenModel() { setContent() - val title = composeTestRule.activity.getString( + val title = targetContext.getString( R.string.notifications_enabled_conversation_pref_title, ) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.NotificationsClicked) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.NotificationsClicked) + } } } @@ -78,11 +85,13 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) - val title = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val title = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.SendSoundChanged(false)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.SendSoundChanged(false)) + } } } @@ -92,10 +101,10 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) - val debugTitle = composeTestRule.activity.getString(R.string.debug_category_pref_title) + val debugTitle = targetContext.getString(R.string.debug_category_pref_title) composeTestRule.onNodeWithText(debugTitle).assertDoesNotExist() - val dumpSmsTitle = composeTestRule.activity.getString(R.string.dump_sms_pref_title) + val dumpSmsTitle = targetContext.getString(R.string.dump_sms_pref_title) composeTestRule.onNodeWithText(dumpSmsTitle).assertDoesNotExist() } @@ -109,13 +118,13 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) - val debugTitle = composeTestRule.activity.getString(R.string.debug_category_pref_title) + val debugTitle = targetContext.getString(R.string.debug_category_pref_title) composeTestRule.onNodeWithText(debugTitle).assertIsDisplayed() - val dumpSmsTitle = composeTestRule.activity.getString(R.string.dump_sms_pref_title) + val dumpSmsTitle = targetContext.getString(R.string.dump_sms_pref_title) composeTestRule.onNodeWithText(dumpSmsTitle).assertIsDisplayed() - val dumpMmsTitle = composeTestRule.activity.getString(R.string.dump_mms_pref_title) + val dumpMmsTitle = targetContext.getString(R.string.dump_mms_pref_title) composeTestRule.onNodeWithText(dumpMmsTitle).assertIsDisplayed() } @@ -128,11 +137,13 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) - val title = composeTestRule.activity.getString(R.string.dump_sms_pref_title) + val title = targetContext.getString(R.string.dump_sms_pref_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.DumpSmsChanged(true)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.DumpSmsChanged(true)) + } } } @@ -145,11 +156,13 @@ class AppSettingsScreenTest { setContent(appSettings = appSettings) - val title = composeTestRule.activity.getString(R.string.dump_mms_pref_title) + val title = targetContext.getString(R.string.dump_mms_pref_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.DumpMmsChanged(true)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.DumpMmsChanged(true)) + } } } @@ -157,11 +170,13 @@ class AppSettingsScreenTest { fun licensesClick_delegatesToScreenModel() { setContent() - val title = composeTestRule.activity.getString(R.string.menu_license) + val title = targetContext.getString(R.string.menu_license) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.LicensesClicked) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.LicensesClicked) + } } } @@ -169,19 +184,12 @@ class AppSettingsScreenTest { fun advancedSettings_shownWhenTopLevel() { var advancedClicks = 0 - composeTestRule.setContent { - AppTheme { - AppSettingsScreen( - appSettings = AppSettingsUiState(), - onAction = screenModel::onAction, - onNavigateBack = {}, - isTopLevel = true, - onAdvancedClick = { advancedClicks += 1 }, - ) - } - } + setContent( + isTopLevel = true, + onAdvancedClick = { advancedClicks += 1 }, + ) - val advancedTitle = composeTestRule.activity.getString(R.string.advanced_settings) + val advancedTitle = targetContext.getString(R.string.advanced_settings) composeTestRule.onNodeWithText(advancedTitle).assertIsDisplayed() composeTestRule.onNodeWithText(advancedTitle).performClick() @@ -192,24 +200,16 @@ class AppSettingsScreenTest { @Test fun advancedSettings_hiddenWhenNotTopLevel() { - composeTestRule.setContent { - AppTheme { - AppSettingsScreen( - appSettings = AppSettingsUiState(), - onAction = screenModel::onAction, - onNavigateBack = {}, - isTopLevel = false, - onAdvancedClick = null, - ) - } - } + setContent(isTopLevel = false) - val advancedTitle = composeTestRule.activity.getString(R.string.advanced_settings) + val advancedTitle = targetContext.getString(R.string.advanced_settings) composeTestRule.onNodeWithText(advancedTitle).assertDoesNotExist() } private fun setContent( appSettings: AppSettingsUiState = AppSettingsUiState(), + isTopLevel: Boolean = false, + onAdvancedClick: (() -> Unit)? = null, ) { composeTestRule.setContent { AppTheme { @@ -217,6 +217,8 @@ class AppSettingsScreenTest { appSettings = appSettings, onAction = screenModel::onAction, onNavigateBack = {}, + isTopLevel = isTopLevel, + onAdvancedClick = onAdvancedClick, ) } } diff --git a/app/src/androidTest/java/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt similarity index 74% rename from app/src/androidTest/java/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt rename to app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt index c8ba61cbe..4919f9494 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/screen/SettingsScreenTest.kt @@ -1,16 +1,18 @@ package com.android.messaging.ui.appsettings.screen -import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.common.test.helpers.targetContext import com.android.messaging.R +import com.android.messaging.testutil.TestLifecycleOwner import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState import com.android.messaging.ui.appsettings.screen.model.SettingsUiState import com.android.messaging.ui.appsettings.subscription.model.SubscriptionUiState @@ -24,11 +26,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class SettingsScreenTest { @get:Rule - val composeTestRule = createAndroidComposeRule() + val composeTestRule = createComposeRule() private val fakeUiStateFlow = MutableStateFlow(createSingleSimState()) private lateinit var screenModel: SettingsScreenModel @@ -45,12 +49,12 @@ class SettingsScreenTest { fun singleSim_skipsMainScreen_showsAppSettings() { fakeUiStateFlow.value = createSingleSimState() - setScreenContent() + setContent() - val generalTitle = composeTestRule.activity.getString(R.string.settings_activity_title) + val generalTitle = targetContext.getString(R.string.settings_activity_title) composeTestRule.onNodeWithText(generalTitle).assertIsDisplayed() - val sendSoundTitle = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val sendSoundTitle = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(sendSoundTitle).assertIsDisplayed() } @@ -58,9 +62,9 @@ class SettingsScreenTest { fun multiSim_showsMainScreen_withSubscriptions() { fakeUiStateFlow.value = createMultiSimState() - setScreenContent() + setContent() - val settingsTitle = composeTestRule.activity.getString(R.string.settings_activity_title) + val settingsTitle = targetContext.getString(R.string.settings_activity_title) composeTestRule.onNodeWithText(settingsTitle).assertIsDisplayed() composeTestRule.onNodeWithText("SIM 1").assertIsDisplayed() @@ -71,13 +75,13 @@ class SettingsScreenTest { fun multiSim_generalSettingsClick_navigatesToAppSettings() { fakeUiStateFlow.value = createMultiSimState() - setScreenContent() + setContent() - val generalSettings = composeTestRule.activity.getString(R.string.general_settings) + val generalSettings = targetContext.getString(R.string.general_settings) composeTestRule.onNodeWithText(generalSettings).performClick() composeTestRule.waitForIdle() - val sendSoundTitle = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val sendSoundTitle = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(sendSoundTitle).assertIsDisplayed() } @@ -92,25 +96,17 @@ class SettingsScreenTest { ) } - composeTestRule.setContent { - CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { - AppTheme { - SettingsScreen( - effectHandler = effectHandler, - onNavigateBack = {}, - screenModel = screenModel, - ) - } - } - } + setContent(lifecycleOwner = lifecycleOwner) composeTestRule.runOnIdle { lifecycleOwner.moveTo(state = Lifecycle.State.RESUMED) } composeTestRule.waitForIdle() - verify(atLeast = 1) { - screenModel.refreshState() + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.refreshState() + } } } @@ -118,9 +114,9 @@ class SettingsScreenTest { fun singleSim_showsAdvancedSettings() { fakeUiStateFlow.value = createSingleSimState() - setScreenContent() + setContent() - val advancedTitle = composeTestRule.activity.getString(R.string.advanced_settings) + val advancedTitle = targetContext.getString(R.string.advanced_settings) composeTestRule.onNodeWithText(advancedTitle).assertIsDisplayed() } @@ -128,9 +124,9 @@ class SettingsScreenTest { fun singleSim_disablingLastSubscription_hidesAdvancedSettings() { fakeUiStateFlow.value = createSingleSimState() - setScreenContent() + setContent() - val advancedTitle = composeTestRule.activity.getString(R.string.advanced_settings) + val advancedTitle = targetContext.getString(R.string.advanced_settings) composeTestRule.onNodeWithText(advancedTitle).assertIsDisplayed() fakeUiStateFlow.value = createSingleSimState().copy( @@ -147,12 +143,12 @@ class SettingsScreenTest { subscriptionSettings = persistentListOf(), ) - setScreenContent() + setContent() - val sendSoundTitle = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val sendSoundTitle = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(sendSoundTitle).assertIsDisplayed() - val advancedTitle = composeTestRule.activity.getString(R.string.advanced_settings) + val advancedTitle = targetContext.getString(R.string.advanced_settings) composeTestRule.onNodeWithText(advancedTitle).assertDoesNotExist() } @@ -160,9 +156,9 @@ class SettingsScreenTest { fun multiSim_topLevelIntent_showsAppSettingsDirectly() { fakeUiStateFlow.value = createMultiSimState() - setScreenContent(isTopLevelIntent = true) + setContent(isTopLevelIntent = true) - val sendSoundTitle = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val sendSoundTitle = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(sendSoundTitle).assertIsDisplayed() composeTestRule.onNodeWithText("SIM 1").assertDoesNotExist() @@ -173,12 +169,12 @@ class SettingsScreenTest { fun multiSim_disablingOpenedSubscription_navigatesBackToMain() { fakeUiStateFlow.value = createMultiSimState() - setScreenContent( + setContent( intentSubId = 2, intentSubTitle = "SIM 2", ) - val phoneNumberTitle = composeTestRule.activity.getString( + val phoneNumberTitle = targetContext.getString( R.string.mms_phone_number_pref_title, ) composeTestRule.onNodeWithText(phoneNumberTitle).assertIsDisplayed() @@ -190,7 +186,7 @@ class SettingsScreenTest { ) composeTestRule.waitForIdle() - val mainTitle = composeTestRule.activity.getString(R.string.settings_activity_title) + val mainTitle = targetContext.getString(R.string.settings_activity_title) composeTestRule.onNodeWithText(mainTitle).assertIsDisplayed() composeTestRule.onNodeWithText("SIM 1").assertIsDisplayed() composeTestRule.onNodeWithText("SIM 2").assertDoesNotExist() @@ -200,12 +196,12 @@ class SettingsScreenTest { fun singleSim_disablingOpenedSubscription_navigatesBackToAppSettings() { fakeUiStateFlow.value = createSingleSimState() - setScreenContent( + setContent( intentSubId = 1, intentSubTitle = "Advanced Settings", ) - val phoneNumberTitle = composeTestRule.activity.getString( + val phoneNumberTitle = targetContext.getString( R.string.mms_phone_number_pref_title, ) composeTestRule.onNodeWithText(phoneNumberTitle).assertIsDisplayed() @@ -215,7 +211,7 @@ class SettingsScreenTest { ) composeTestRule.waitForIdle() - val sendSoundTitle = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val sendSoundTitle = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(sendSoundTitle).assertIsDisplayed() composeTestRule.onNodeWithText(phoneNumberTitle).assertDoesNotExist() } @@ -224,12 +220,12 @@ class SettingsScreenTest { fun multiSim_disablingAllSubscriptions_navigatesToAppSettings() { fakeUiStateFlow.value = createMultiSimState() - setScreenContent( + setContent( intentSubId = 2, intentSubTitle = "SIM 2", ) - val phoneNumberTitle = composeTestRule.activity.getString( + val phoneNumberTitle = targetContext.getString( R.string.mms_phone_number_pref_title, ) composeTestRule.onNodeWithText(phoneNumberTitle).assertIsDisplayed() @@ -240,7 +236,7 @@ class SettingsScreenTest { ) composeTestRule.waitForIdle() - val sendSoundTitle = composeTestRule.activity.getString(R.string.send_sound_pref_title) + val sendSoundTitle = targetContext.getString(R.string.send_sound_pref_title) composeTestRule.onNodeWithText(sendSoundTitle).assertIsDisplayed() composeTestRule.onNodeWithText(phoneNumberTitle).assertDoesNotExist() } @@ -249,12 +245,12 @@ class SettingsScreenTest { fun multiSim_disablingOtherSubscription_keepsCurrentSubscriptionScreen() { fakeUiStateFlow.value = createMultiSimState() - setScreenContent( + setContent( intentSubId = 1, intentSubTitle = "SIM 1", ) - val phoneNumberTitle = composeTestRule.activity.getString( + val phoneNumberTitle = targetContext.getString( R.string.mms_phone_number_pref_title, ) composeTestRule.onNodeWithText(phoneNumberTitle).assertIsDisplayed() @@ -270,21 +266,32 @@ class SettingsScreenTest { composeTestRule.onNodeWithText("SIM 1").assertIsDisplayed() } - private fun setScreenContent( + private fun setContent( intentSubId: Int = 0, intentSubTitle: String? = null, isTopLevelIntent: Boolean = false, + lifecycleOwner: LifecycleOwner? = null, ) { composeTestRule.setContent { - AppTheme { - SettingsScreen( - effectHandler = effectHandler, - onNavigateBack = {}, - intentSubId = intentSubId, - intentSubTitle = intentSubTitle, - isTopLevelIntent = isTopLevelIntent, - screenModel = screenModel, - ) + val content = @Composable { + AppTheme { + SettingsScreen( + effectHandler = effectHandler, + onNavigateBack = {}, + intentSubId = intentSubId, + intentSubTitle = intentSubTitle, + isTopLevelIntent = isTopLevelIntent, + screenModel = screenModel, + ) + } + } + + if (lifecycleOwner == null) { + content() + } else { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + content() + } } } } @@ -331,21 +338,4 @@ class SettingsScreenTest { areSubscriptionsLoaded = true, ) } - - private class TestLifecycleOwner( - initialState: Lifecycle.State, - ) : LifecycleOwner { - private val lifecycleRegistry = LifecycleRegistry(this) - - init { - lifecycleRegistry.currentState = initialState - } - - override val lifecycle: Lifecycle - get() = lifecycleRegistry - - fun moveTo(state: Lifecycle.State) { - lifecycleRegistry.currentState = state - } - } } diff --git a/app/src/androidTest/java/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt similarity index 74% rename from app/src/androidTest/java/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt rename to app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt index d0b110a39..3f83a5cf5 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt @@ -1,12 +1,13 @@ package com.android.messaging.ui.appsettings.subscription.ui -import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.common.test.helpers.targetContext import com.android.messaging.R import com.android.messaging.ui.appsettings.screen.SettingsScreenModel import com.android.messaging.ui.appsettings.screen.model.SettingsAction as Action @@ -17,11 +18,13 @@ import io.mockk.verify import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class SubscriptionSettingsScreenTest { @get:Rule - val composeTestRule = createAndroidComposeRule() + val composeTestRule = createComposeRule() private lateinit var screenModel: SettingsScreenModel @@ -30,22 +33,12 @@ class SubscriptionSettingsScreenTest { screenModel = mockk(relaxed = true) } - @Test - fun mmsCategoryHeader_isDisplayed() { - setContent(subscriptionSettings = createDefaultSubscription()) - - val mmsTitle = composeTestRule.activity.getString( - R.string.mms_messaging_category_pref_title, - ) - composeTestRule.onNodeWithText(mmsTitle).assertIsDisplayed() - } - @Test fun groupMms_shownWhenSupported() { val sub = createDefaultSubscription(isGroupMmsSupported = true) setContent(subscriptionSettings = sub) - val groupMmsTitle = composeTestRule.activity.getString(R.string.group_mms_pref_title) + val groupMmsTitle = targetContext.getString(R.string.group_mms_pref_title) composeTestRule.onNodeWithText(groupMmsTitle).assertIsDisplayed() } @@ -54,7 +47,7 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(isGroupMmsSupported = false) setContent(subscriptionSettings = sub) - val groupMmsTitle = composeTestRule.activity.getString(R.string.group_mms_pref_title) + val groupMmsTitle = targetContext.getString(R.string.group_mms_pref_title) composeTestRule.onNodeWithText(groupMmsTitle).assertDoesNotExist() } @@ -66,26 +59,26 @@ class SubscriptionSettingsScreenTest { ) setContent(subscriptionSettings = sub) - val groupMmsTitle = composeTestRule.activity.getString(R.string.group_mms_pref_title) + val groupMmsTitle = targetContext.getString(R.string.group_mms_pref_title) composeTestRule.onNodeWithText(groupMmsTitle).performClick() composeTestRule.waitForIdle() - val disableLabel = composeTestRule.activity.getString(R.string.disable_group_mms) + val disableLabel = targetContext.getString(R.string.disable_group_mms) composeTestRule.onNodeWithText(disableLabel).assertIsDisplayed() - val okText = composeTestRule.activity.getString(android.R.string.ok) + val okText = targetContext.getString(android.R.string.ok) composeTestRule.onNodeWithText(okText).assertIsDisplayed() - val cancelText = composeTestRule.activity.getString(android.R.string.cancel) + val cancelText = targetContext.getString(android.R.string.cancel) composeTestRule.onNodeWithText(cancelText).assertIsDisplayed() } @Test fun phoneNumberItem_displaysCurrentNumber() { - val sub = createDefaultSubscription(displayDetail = "+1234567890") + val sub = createDefaultSubscription(displayDetail = "+1 555-010-2034") setContent(subscriptionSettings = sub) - composeTestRule.onNodeWithText("+1234567890").assertIsDisplayed() + composeTestRule.onNodeWithText("+1 555-010-2034").assertIsDisplayed() } @Test @@ -93,11 +86,11 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(phoneNumber = "+1234567890") setContent(subscriptionSettings = sub) - val phoneTitle = composeTestRule.activity.getString(R.string.mms_phone_number_pref_title) + val phoneTitle = targetContext.getString(R.string.mms_phone_number_pref_title) composeTestRule.onNodeWithText(phoneTitle).performClick() composeTestRule.waitForIdle() - val okText = composeTestRule.activity.getString(android.R.string.ok) + val okText = targetContext.getString(android.R.string.ok) composeTestRule.onNodeWithText(okText).assertIsDisplayed() } @@ -109,11 +102,13 @@ class SubscriptionSettingsScreenTest { ) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.auto_retrieve_mms_pref_title) + val title = targetContext.getString(R.string.auto_retrieve_mms_pref_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.AutoRetrieveMmsChanged(1, false)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.AutoRetrieveMmsChanged(1, false)) + } } } @@ -125,7 +120,7 @@ class SubscriptionSettingsScreenTest { ) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString( + val title = targetContext.getString( R.string.auto_retrieve_mms_when_roaming_pref_title, ) composeTestRule.onNodeWithText(title).assertIsNotEnabled() @@ -139,7 +134,7 @@ class SubscriptionSettingsScreenTest { ) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString( + val title = targetContext.getString( R.string.auto_retrieve_mms_when_roaming_pref_title, ) composeTestRule.onNodeWithText(title).assertIsEnabled() @@ -150,7 +145,7 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(isDeliveryReportsSupported = true) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.delivery_reports_pref_title) + val title = targetContext.getString(R.string.delivery_reports_pref_title) composeTestRule.onNodeWithText(title).assertIsDisplayed() } @@ -159,7 +154,7 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(isDeliveryReportsSupported = false) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.delivery_reports_pref_title) + val title = targetContext.getString(R.string.delivery_reports_pref_title) composeTestRule.onNodeWithText(title).assertDoesNotExist() } @@ -172,11 +167,13 @@ class SubscriptionSettingsScreenTest { ) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.delivery_reports_pref_title) + val title = targetContext.getString(R.string.delivery_reports_pref_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.DeliveryReportsChanged(1, true)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.DeliveryReportsChanged(1, true)) + } } } @@ -185,7 +182,7 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(isWirelessAlertsSupported = true) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.wireless_alerts_title) + val title = targetContext.getString(R.string.wireless_alerts_title) composeTestRule.onNodeWithText(title).assertIsDisplayed() } @@ -194,7 +191,7 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(isWirelessAlertsSupported = false) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.wireless_alerts_title) + val title = targetContext.getString(R.string.wireless_alerts_title) composeTestRule.onNodeWithText(title).assertDoesNotExist() } @@ -203,11 +200,13 @@ class SubscriptionSettingsScreenTest { val sub = createDefaultSubscription(isWirelessAlertsSupported = true) setContent(subscriptionSettings = sub) - val title = composeTestRule.activity.getString(R.string.wireless_alerts_title) + val title = targetContext.getString(R.string.wireless_alerts_title) composeTestRule.onNodeWithText(title).performClick() - verify(exactly = 1) { - screenModel.onAction(Action.WirelessAlertsClicked(1)) + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onAction(Action.WirelessAlertsClicked(1)) + } } } @@ -220,7 +219,7 @@ class SubscriptionSettingsScreenTest { setContent(subscriptionSettings = sub) val advancedTitle = - composeTestRule.activity.getString(R.string.advanced_category_pref_title) + targetContext.getString(R.string.advanced_category_pref_title) composeTestRule.onNodeWithText(advancedTitle).assertIsDisplayed() } @@ -233,7 +232,7 @@ class SubscriptionSettingsScreenTest { setContent(subscriptionSettings = sub) val advancedTitle = - composeTestRule.activity.getString(R.string.advanced_category_pref_title) + targetContext.getString(R.string.advanced_category_pref_title) composeTestRule.onNodeWithText(advancedTitle).assertDoesNotExist() } @@ -246,15 +245,15 @@ class SubscriptionSettingsScreenTest { ) setContent(subscriptionSettings = sub) - val groupMmsTitle = composeTestRule.activity.getString(R.string.group_mms_pref_title) + val groupMmsTitle = targetContext.getString(R.string.group_mms_pref_title) composeTestRule.onNodeWithText(groupMmsTitle).assertIsNotEnabled() - val autoRetrieveTitle = composeTestRule.activity.getString( + val autoRetrieveTitle = targetContext.getString( R.string.auto_retrieve_mms_pref_title, ) composeTestRule.onNodeWithText(autoRetrieveTitle).assertIsNotEnabled() - val deliveryTitle = composeTestRule.activity.getString(R.string.delivery_reports_pref_title) + val deliveryTitle = targetContext.getString(R.string.delivery_reports_pref_title) composeTestRule.onNodeWithText(deliveryTitle).assertIsNotEnabled() } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt similarity index 84% rename from app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt rename to app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt index 37a65dcd6..114767cc2 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationUserFlowTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.pressBackUnconditionally import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions @@ -17,6 +18,7 @@ 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.testutil.TEST_WAIT_TIMEOUT_MILLIS import com.android.messaging.ui.conversationlist.ConversationListActivity import org.junit.Rule import org.junit.Test @@ -32,7 +34,7 @@ class ConversationUserFlowTest { val messagingRule = MessagingTestRule() @get:Rule - val composeRule = createEmptyComposeRule() + val composeTestRule = createEmptyComposeRule() @OptIn(ExperimentalTestApi::class) @Test @@ -56,33 +58,34 @@ class ConversationUserFlowTest { ), ) - composeRule.waitUntilAtLeastOneExists( + composeTestRule.waitUntilAtLeastOneExists( matcher = hasTestTag(testTag = CONVERSATION_TEXT_FIELD_TEST_TAG), timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS, ) - composeRule + composeTestRule .onNodeWithTag(testTag = CONVERSATION_MESSAGES_LIST_TEST_TAG) .assertIsDisplayed() - composeRule + composeTestRule .onNodeWithTag(testTag = CONVERSATION_COMPOSE_BAR_TEST_TAG) .assertIsDisplayed() - composeRule + composeTestRule .onNodeWithTag(testTag = CONVERSATION_TEXT_FIELD_TEST_TAG) .assertIsDisplayed() - composeRule + composeTestRule .onNodeWithTag( testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, useUnmergedTree = true, ) .assertIsDisplayed() - } - } - private companion object { - private const val TEST_WAIT_TIMEOUT_MILLIS = 5_000L + pressBackUnconditionally() + + onView(withId(android.R.id.list)) + .check(matches(isDisplayed())) + } } } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt similarity index 59% rename from app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt rename to app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt index 93b83f782..6cf13bfcd 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt @@ -7,15 +7,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Density +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.core.AppTheme @@ -23,26 +30,19 @@ import kotlinx.collections.immutable.persistentListOf import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith -private const val MESSAGE_ID = "message-id" -private const val CONVERSATION_ID = "conversation-id" -private const val HEIGHT_ASSERTION_DELTA_DP = 0.5f -private const val LINK_ONLY_TEXT = "https://example.com" -private const val MESSAGE_TEST_TAG = "conversation-message" -private const val MINIMAL_FONT_SCALE = 0.85f -private const val PLAIN_TEXT = "plain outgoing message" -private const val TIMESTAMP = 1_700_000_000_000L - -internal class ConversationMessageLinkLongClickTest { +@RunWith(AndroidJUnit4::class) +class ConversationMessageLinkLongClickTest { @get:Rule - val composeRule = createComposeRule() + val composeTestRule = createComposeRule() @Test fun longClickOutgoingLinkOnlyMessageSelectsMessage() { var externalUriClickCount = 0 var messageLongClickCount = 0 - composeRule.setContent { + composeTestRule.setContent { AppTheme { ConversationMessage( message = outgoingMessage(text = LINK_ONLY_TEXT), @@ -56,23 +56,23 @@ internal class ConversationMessageLinkLongClickTest { } } - composeRule.waitForIdle() + awaitLinkAnnotated(text = LINK_ONLY_TEXT) - composeRule + composeTestRule .onNodeWithText(text = LINK_ONLY_TEXT, useUnmergedTree = true) .performClick() - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, externalUriClickCount) } - composeRule + composeTestRule .onNodeWithText(text = LINK_ONLY_TEXT, useUnmergedTree = true) .performTouchInput { longClick(position = center) } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, externalUriClickCount) assertEquals(1, messageLongClickCount) } @@ -86,7 +86,7 @@ internal class ConversationMessageLinkLongClickTest { var isSelected by mutableStateOf(false) var isSelectionMode by mutableStateOf(false) - composeRule.setContent { + composeTestRule.setContent { AppTheme { ConversationMessage( message = outgoingMessage(text = LINK_ONLY_TEXT), @@ -108,15 +108,15 @@ internal class ConversationMessageLinkLongClickTest { } } - composeRule.waitForIdle() + awaitLinkAnnotated(text = LINK_ONLY_TEXT) - composeRule + composeTestRule .onNodeWithText(text = LINK_ONLY_TEXT, useUnmergedTree = true) .performTouchInput { longClick(position = center) } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(0, externalUriClickCount) assertEquals(0, messageClickCount) assertEquals(1, messageLongClickCount) @@ -129,7 +129,7 @@ internal class ConversationMessageLinkLongClickTest { fun longClickOutgoingPlainTextMessageSelectsMessageOnce() { var messageLongClickCount = 0 - composeRule.setContent { + composeTestRule.setContent { AppTheme { ConversationMessage( message = outgoingMessage(text = PLAIN_TEXT), @@ -140,15 +140,19 @@ internal class ConversationMessageLinkLongClickTest { } } - composeRule.waitForIdle() + composeTestRule.waitForIdle() - composeRule - .onNodeWithText(text = PLAIN_TEXT, useUnmergedTree = true) + composeTestRule + .onNodeWithTag( + testTag = conversationMessageBubbleTestTag( + messageId = MESSAGE_ID, + ), + ) .performTouchInput { longClick(position = center) } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, messageLongClickCount) } } @@ -158,7 +162,7 @@ internal class ConversationMessageLinkLongClickTest { var isSelected by mutableStateOf(false) var isSelectionMode by mutableStateOf(false) - composeRule.setContent { + composeTestRule.setContent { val density = LocalDensity.current CompositionLocalProvider( @@ -178,29 +182,29 @@ internal class ConversationMessageLinkLongClickTest { } } - composeRule.waitForIdle() + composeTestRule.waitForIdle() - val unselectedHeight = composeRule + val unselectedHeight = composeTestRule .onNodeWithTag(testTag = MESSAGE_TEST_TAG) .getUnclippedBoundsInRoot() .let { bounds -> bounds.bottom - bounds.top } - composeRule.runOnIdle { + composeTestRule.runOnIdle { isSelected = true isSelectionMode = true } - composeRule.waitForIdle() + composeTestRule.waitForIdle() - val selectedHeight = composeRule + val selectedHeight = composeTestRule .onNodeWithTag(testTag = MESSAGE_TEST_TAG) .getUnclippedBoundsInRoot() .let { bounds -> bounds.bottom - bounds.top } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals( unselectedHeight.value, selectedHeight.value, @@ -208,39 +212,62 @@ internal class ConversationMessageLinkLongClickTest { ) } } -} -private fun outgoingMessage(text: String): ConversationMessageUiModel { - return ConversationMessageUiModel( - messageId = MESSAGE_ID, - conversationId = CONVERSATION_ID, - text = text, - parts = persistentListOf( - ConversationMessagePartUiModel.Text( - text = text, + private fun awaitLinkAnnotated(text: String) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule + .onAllNodesWithText(text = text, useUnmergedTree = true) + .fetchSemanticsNodes() + .any { node -> + node.config + .getOrNull(SemanticsProperties.Text) + ?.any { it.hasLinkAnnotations(start = 0, end = it.length) } == true + } + } + } + + private fun outgoingMessage(text: String): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = MESSAGE_ID, + conversationId = CONVERSATION_ID, + text = text, + parts = persistentListOf( + ConversationMessagePartUiModel.Text( + text = text, + ), ), - ), - sentTimestamp = TIMESTAMP, - receivedTimestamp = TIMESTAMP, - displayTimestamp = TIMESTAMP, - status = ConversationMessageUiModel.Status.Outgoing.Complete, - isIncoming = false, - senderDisplayName = null, - senderAvatarUri = null, - senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, - senderContactLookupKey = null, - senderNormalizedDestination = null, - senderParticipantId = null, - selfParticipantId = null, - canClusterWithPrevious = false, - canClusterWithNext = false, - canCopyMessageToClipboard = true, - canDownloadMessage = false, - canForwardMessage = true, - canResendMessage = false, - canSaveAttachments = false, - mmsDownload = null, - mmsSubject = null, - protocol = ConversationMessageUiModel.Protocol.SMS, - ) + sentTimestamp = TIMESTAMP, + receivedTimestamp = TIMESTAMP, + displayTimestamp = TIMESTAMP, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } + + private companion object { + private const val MESSAGE_ID = "message-id" + private const val HEIGHT_ASSERTION_DELTA_DP = 0.5f + private const val LINK_ONLY_TEXT = "https://example.com" + private const val MESSAGE_TEST_TAG = "conversation-message" + private const val MINIMAL_FONT_SCALE = 0.85f + private const val PLAIN_TEXT = "plain outgoing message" + private const val TIMESTAMP = 1_700_000_000_000L + } } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarLayoutTest.kt similarity index 54% rename from app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt rename to app/src/androidTest/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarLayoutTest.kt index 538dc0176..e553afd33 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarLayoutTest.kt @@ -5,25 +5,28 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.core.AppTheme import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ConversationTopAppBarLayoutTest { -internal class ConversationTopAppBarTest { @get:Rule - val composeRule = createComposeRule() + val composeTestRule = createComposeRule() @OptIn(ExperimentalMaterial3Api::class) @Test - fun titleTouchTargetUsesSmallTopAppBarHeight() { - composeRule.setContent { + fun title_fillsAppBarHeightForTouchTarget() { + composeTestRule.setContent { AppTheme { ConversationTopAppBar( - metadata = conversationMetadata(), - isCallVisible = true, + metadata = presentMetadata, onAddPeopleClick = {}, onTitleClick = {}, onNavigateBack = {}, @@ -31,22 +34,22 @@ internal class ConversationTopAppBarTest { } } - composeRule + composeTestRule .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) .assertHeightIsEqualTo(expectedHeight = TopAppBarDefaults.TopAppBarExpandedHeight) } -} -private fun conversationMetadata(): ConversationMetadataUiState { - return ConversationMetadataUiState.Present( - title = "+372 5440 0024", - selfParticipantId = "self-participant-id", - avatar = ConversationMetadataUiState.Avatar.Single(photoUri = null), - participantCount = 1, - otherParticipantDisplayDestination = "+372 5440 0024", - otherParticipantPhoneNumber = "+37254400024", - otherParticipantContactLookupKey = null, - isArchived = false, - composerAvailability = ConversationComposerAvailability.Editable, - ) + private companion object { + private val presentMetadata = ConversationMetadataUiState.Present( + title = "Carol", + selfParticipantId = "self-participant-id", + avatar = ConversationMetadataUiState.Avatar.Single(photoUri = null), + participantCount = 1, + otherParticipantDisplayDestination = "+372 5440 0024", + otherParticipantPhoneNumber = "+37254400024", + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + } } From f009dcea5baeed0f0f580778161bc99b2dd9bff8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:13:08 +0300 Subject: [PATCH 16/38] Add shared conversation Compose test fixtures --- .../ui/conversation/TestSubscriptions.kt | 25 ++ .../BaseConversationMediaPickerTest.kt | 217 ++++++++++++++ ...seConversationMediaCaptureComponentTest.kt | 190 ++++++++++++ .../BaseConversationMediaPickerReviewTest.kt | 209 +++++++++++++ ...onversationInlineAudioAttachmentRowTest.kt | 47 +++ ...versationMessageAttachmentRenderingTest.kt | 259 +++++++++++++++++ ...ationMessageAttachmentRenderingFixtures.kt | 275 ++++++++++++++++++ .../BaseConversationMessageRenderingTest.kt | 237 +++++++++++++++ .../BaseRecipientSelectionContactRowTest.kt | 129 ++++++++ .../RecipientSelectionContactRowFixtures.kt | 167 +++++++++++ .../screen/BaseConversationScreenTest.kt | 204 +++++++++++++ .../BaseConversationScreenDialogsTest.kt | 79 +++++ ...BaseConversationScreenEffectsActionTest.kt | 167 +++++++++++ ...rsationScreenSimSelectorIntegrationTest.kt | 56 ++++ 14 files changed, 2261 insertions(+) create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/TestSubscriptions.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/BaseConversationMediaPickerTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/BaseConversationMediaCaptureComponentTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/BaseConversationMediaPickerReviewTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/BaseConversationInlineAudioAttachmentRowTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentRenderingFixtures.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/BaseRecipientSelectionContactRowTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowFixtures.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/dialogs/BaseConversationScreenDialogsTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt create mode 100644 app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/simselector/BaseConversationScreenSimSelectorIntegrationTest.kt diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/TestSubscriptions.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/TestSubscriptions.kt new file mode 100644 index 000000000..13b1687ed --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/TestSubscriptions.kt @@ -0,0 +1,25 @@ +package com.android.messaging.ui.conversation + +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.data.subscription.model.Subscription + +internal const val TEST_ATT_SUBSCRIPTION_NAME = "AT&T Business" +internal const val TEST_VERIZON_SUBSCRIPTION_NAME = "Verizon" + +internal val testAttSubscription = Subscription( + selfParticipantId = "self-2", + subId = 2, + label = ConversationSubscriptionLabel.Named(name = TEST_ATT_SUBSCRIPTION_NAME), + displayDestination = "+1 555-111-2222", + displaySlotId = 2, + color = 0xFFE97E6A.toInt(), +) + +internal val testVerizonSubscription = Subscription( + selfParticipantId = "self-1", + subId = 1, + label = ConversationSubscriptionLabel.Named(name = TEST_VERIZON_SUBSCRIPTION_NAME), + displayDestination = "+1 555-867-5309", + displaySlotId = 1, + color = 0xFF5E9BE8.toInt(), +) diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/BaseConversationMediaPickerTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/BaseConversationMediaPickerTest.kt new file mode 100644 index 000000000..3e307f6bc --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/BaseConversationMediaPickerTest.kt @@ -0,0 +1,217 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.activity.ComponentActivity +import androidx.camera.core.SurfaceRequest +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.core.AppTheme +import com.android.messaging.util.ContentType +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseConversationMediaPickerTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + protected lateinit var cameraController: ConversationCameraController + protected lateinit var cameraState: CameraControllerState + + protected val onClose = mockk<() -> Unit>(relaxed = true) + protected val onAttachmentStartRequest = mockk<() -> Boolean>() + protected val onCapturedMediaReady = mockk<(ConversationCapturedMedia) -> Unit>(relaxed = true) + protected val onShowReview = mockk<(String) -> Unit>(relaxed = true) + protected val onCaptureModeChange = mockk<(ConversationCaptureMode) -> Unit>(relaxed = true) + protected val onAttachmentPreviewClick = + mockk<(ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit>(relaxed = true) + protected val onAttachmentCaptionChange = mockk<(String, String) -> Unit>(relaxed = true) + protected val onAttachmentRemove = mockk<(String) -> Unit>(relaxed = true) + protected val onRequestAudioPermission = mockk<() -> Unit>(relaxed = true) + protected val onRequestCameraPermission = mockk<() -> Unit>(relaxed = true) + protected val onSendClick = mockk<() -> Unit>(relaxed = true) + protected val onClearReview = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUpBaseConversationMediaPickerTest() { + clearAllMocks() + cameraState = CameraControllerState() + cameraController = mockk(relaxed = true) + every { cameraController.hasFlashUnit } returns cameraState.hasFlashUnit + every { cameraController.isPhotoCaptureInProgress } returns + cameraState.isPhotoCaptureInProgress + every { cameraController.isRecording } returns cameraState.isRecording + every { cameraController.photoFlashMode } returns cameraState.photoFlashMode + every { cameraController.recordingDurationMillis } returns + cameraState.recordingDurationMillis + every { cameraController.surfaceRequest } returns cameraState.surfaceRequest + every { onAttachmentStartRequest.invoke() } returns true + } + + protected fun setCaptureRouteContent( + audioPermissionGranted: Boolean = true, + captureMode: ConversationCaptureMode = ConversationCaptureMode.Photo, + cameraPermissionGranted: Boolean = true, + ) { + setThemedContent { + ConversationMediaCaptureRoute( + modifier = Modifier.fillMaxSize(), + cameraController = cameraController, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onShowReview = onShowReview, + onCapturedMediaReady = onCapturedMediaReady, + onCaptureModeChange = onCaptureModeChange, + ) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + protected fun setScaffoldContent( + visualAttachments: ImmutableList = + persistentListOf(imageAttachment()), + captureMode: ConversationCaptureMode = ConversationCaptureMode.Photo, + reviewContentUri: String? = IMAGE_CONTENT_URI, + reviewRequestSequence: Int = 0, + isReviewVisible: Boolean, + isSendActionEnabled: Boolean = true, + cameraPermissionGranted: Boolean = false, + audioPermissionGranted: Boolean = true, + photoPickerSourceContentUriByAttachmentContentUri: + ImmutableMap = persistentMapOf(), + ) { + setThemedContent { + val sheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + skipHiddenState = true, + ) + val scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = sheetState, + ) + + ConversationMediaPickerScaffold( + modifier = Modifier.fillMaxSize(), + cameraController = cameraController, + scaffoldState = scaffoldState, + photoPickerSheetContent = { + Box( + modifier = Modifier.size(1.dp), + ) + }, + visualAttachments = visualAttachments, + conversationTitle = CONVERSATION_TITLE, + captureMode = captureMode, + reviewContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + isReviewVisible = isReviewVisible, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + onShowReview = onShowReview, + onClearReview = onClearReview, + onCaptureModeChange = onCaptureModeChange, + ) + } + } + + protected fun clickCaptureControl() { + composeTestRule + .onNodeWithTag(CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG) + .performClick() + } + + protected fun imageAttachment( + contentUri: String = IMAGE_CONTENT_URI, + captionText: String = IMAGE_CAPTION, + ): ComposerAttachmentUiModel.Resolved.VisualMedia.Image { + return ComposerAttachmentUiModel.Resolved.VisualMedia.Image( + key = IMAGE_KEY, + contentType = ContentType.IMAGE_JPEG, + contentUri = contentUri, + captionText = captionText, + width = IMAGE_WIDTH, + height = IMAGE_HEIGHT, + ) + } + + protected fun capturedPhoto(): ConversationCapturedMedia { + return ConversationCapturedMedia( + contentUri = CAPTURED_PHOTO_URI, + contentType = ContentType.IMAGE_JPEG, + ) + } + + protected fun capturedVideo(): ConversationCapturedMedia { + return ConversationCapturedMedia( + contentUri = CAPTURED_VIDEO_URI, + contentType = ContentType.VIDEO_MP4, + ) + } + + private fun setThemedContent(content: @Composable () -> Unit) { + composeTestRule.setContent { + AppTheme(content = content) + } + } + + protected class CameraControllerState( + val hasFlashUnit: MutableStateFlow = MutableStateFlow(false), + val isPhotoCaptureInProgress: MutableStateFlow = MutableStateFlow(false), + val isRecording: MutableStateFlow = MutableStateFlow(false), + val photoFlashMode: MutableStateFlow = + MutableStateFlow(ConversationPhotoFlashMode.Off), + val recordingDurationMillis: MutableStateFlow = MutableStateFlow(0L), + val surfaceRequest: MutableStateFlow = + MutableStateFlow(null), + ) + + protected companion object { + const val CONVERSATION_TITLE = "Weekend plan" + const val IMAGE_KEY = "image-1" + const val IMAGE_CONTENT_URI = "content://media/picker/image/1" + const val IMAGE_CAPTION = "Ready to send" + const val CAPTURED_PHOTO_URI = "content://media/picker/captured/photo" + const val CAPTURED_VIDEO_URI = "content://media/picker/captured/video" + const val IMAGE_WIDTH = 640 + const val IMAGE_HEIGHT = 480 + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/BaseConversationMediaCaptureComponentTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/BaseConversationMediaCaptureComponentTest.kt new file mode 100644 index 000000000..46e74ed46 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/BaseConversationMediaCaptureComponentTest.kt @@ -0,0 +1,190 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseConversationMediaCaptureComponentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + protected val onCloseClick = mockk<() -> Unit>(relaxed = true) + protected val onFlashClick = mockk<() -> Unit>(relaxed = true) + protected val onCaptureClick = mockk<() -> Unit>(relaxed = true) + protected val onPhotoModeClick = mockk<() -> Unit>(relaxed = true) + protected val onSwitchCameraClick = mockk<() -> Unit>(relaxed = true) + protected val onVideoModeClick = mockk<() -> Unit>(relaxed = true) + protected val onRequestAudioPermission = mockk<() -> Unit>(relaxed = true) + protected val onRequestCameraPermission = mockk<() -> Unit>(relaxed = true) + protected val onPhotoCaptureClick = mockk<() -> Unit>(relaxed = true) + protected val onToggleFlashClick = mockk<() -> Unit>(relaxed = true) + protected val onVideoCaptureClick = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUpBaseConversationMediaCaptureComponentTest() { + clearAllMocks() + } + + protected fun setTopBarContent( + captureMode: ConversationCaptureMode = ConversationCaptureMode.Photo, + hasFlashUnit: Boolean = true, + isPhotoCaptureInProgress: Boolean = false, + isRecording: Boolean = false, + photoFlashMode: ConversationPhotoFlashMode = ConversationPhotoFlashMode.Off, + ) { + setThemedContent { + ConversationMediaCaptureTopBar( + captureMode = captureMode, + hasFlashUnit = hasFlashUnit, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + photoFlashMode = photoFlashMode, + onCloseClick = onCloseClick, + onFlashClick = onFlashClick, + ) + } + } + + protected fun setControlsContent( + captureMode: ConversationCaptureMode = ConversationCaptureMode.Photo, + isPhotoCaptureInProgress: Boolean = false, + isRecording: Boolean = false, + recordingDurationMillis: Long = 0L, + ) { + setThemedContent { + ConversationMediaCaptureControls( + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + recordingDurationMillis = recordingDurationMillis, + onCaptureClick = onCaptureClick, + onPhotoModeClick = onPhotoModeClick, + onSwitchCameraClick = onSwitchCameraClick, + onVideoModeClick = onVideoModeClick, + ) + } + } + + protected fun setShutterButtonContent( + captureMode: ConversationCaptureMode, + isPhotoCaptureInProgress: Boolean = false, + isRecording: Boolean = false, + ) { + setThemedContent { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ConversationMediaCaptureShutterButton( + modifier = Modifier + .testTag(CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG), + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + onClick = onCaptureClick, + ) + } + } + } + + protected fun setCaptureContent( + audioPermissionGranted: Boolean = true, + captureMode: ConversationCaptureMode = ConversationCaptureMode.Photo, + cameraPermissionGranted: Boolean = true, + hasFlashUnit: Boolean = true, + isPhotoCaptureInProgress: Boolean = false, + isRecording: Boolean = false, + photoFlashMode: ConversationPhotoFlashMode = ConversationPhotoFlashMode.Off, + recordingDurationMillis: Long = 0L, + ) { + setThemedContent { + ConversationMediaCaptureContent( + modifier = Modifier.fillMaxSize(), + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, + hasFlashUnit = hasFlashUnit, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + photoFlashMode = photoFlashMode, + onCloseClick = onCloseClick, + onRequestAudioPermission = onRequestAudioPermission, + onPhotoCaptureClick = onPhotoCaptureClick, + onPhotoModeClick = onPhotoModeClick, + onSwitchCameraClick = onSwitchCameraClick, + onToggleFlashClick = onToggleFlashClick, + onVideoCaptureClick = onVideoCaptureClick, + onVideoModeClick = onVideoModeClick, + recordingDurationMillis = recordingDurationMillis, + ) + } + } + + protected fun setPreviewSurfaceContent( + cameraPermissionGranted: Boolean, + ) { + setThemedContent { + ConversationMediaCameraPreviewSurface( + modifier = Modifier.fillMaxSize(), + cameraPermissionGranted = cameraPermissionGranted, + contentPadding = PaddingValues(), + surfaceRequest = null, + onRequestCameraPermission = onRequestCameraPermission, + ) + } + } + + protected fun clickCaptureShutterButton() { + composeTestRule + .onNodeWithTag(CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG) + .performClick() + } + + protected fun string(resourceId: Int): String { + return targetContext.getString(resourceId) + } + + protected fun closeDescription(): String { + return string(R.string.conversation_media_picker_close_content_description) + } + + protected fun flashDescription(): String { + return string(R.string.conversation_media_picker_cycle_flash_mode_content_description) + } + + protected fun switchCameraDescription(): String { + return string(R.string.camera_switch_camera_facing) + } + + protected fun photoModeLabel(): String { + return string(R.string.conversation_media_picker_photo_mode) + } + + protected fun videoModeLabel(): String { + return string(R.string.conversation_media_picker_video_mode) + } + + private fun setThemedContent(content: @Composable () -> Unit) { + composeTestRule.setContent { + AppTheme(content = content) + } + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/BaseConversationMediaPickerReviewTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/BaseConversationMediaPickerReviewTest.kt new file mode 100644 index 000000000..363bc9d7b --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/BaseConversationMediaPickerReviewTest.kt @@ -0,0 +1,209 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.conversationMediaReviewPreviewTestTag +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import org.junit.Before +import org.junit.Rule + +private typealias ReviewVisualMedia = ComposerAttachmentUiModel.Resolved.VisualMedia +private typealias ReviewImageAttachment = ComposerAttachmentUiModel.Resolved.VisualMedia.Image +private typealias ReviewVideoAttachment = ComposerAttachmentUiModel.Resolved.VisualMedia.Video + +internal abstract class BaseConversationMediaPickerReviewTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + protected val onAttachmentPreviewClick = mockk<(ReviewVisualMedia) -> Unit>(relaxed = true) + protected val onCaptionChange = mockk<(String, String) -> Unit>(relaxed = true) + protected val onAttachmentRemove = mockk<(String) -> Unit>(relaxed = true) + protected val onAddMoreClick = mockk<() -> Unit>(relaxed = true) + protected val onClearReview = mockk<() -> Unit>(relaxed = true) + protected val onCloseClick = mockk<() -> Unit>(relaxed = true) + protected val onSendClick = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUp() { + unmockkAll() + clearAllMocks() + } + + protected fun setReviewContent( + attachments: ImmutableList = persistentListOf(imageAttachment()), + conversationTitle: String? = CONVERSATION_TITLE, + initiallyReviewedContentUri: String? = null, + reviewRequestSequence: Int = 0, + isSendActionEnabled: Boolean = true, + photoPickerSourceContentUriByAttachmentContentUri: + ImmutableMap = persistentMapOf(), + contentPadding: PaddingValues = PaddingValues(), + ) { + setReviewContent( + attachments = { attachments }, + conversationTitle = { conversationTitle }, + initiallyReviewedContentUri = { initiallyReviewedContentUri }, + reviewRequestSequence = { reviewRequestSequence }, + isSendActionEnabled = { isSendActionEnabled }, + photoPickerSourceContentUriByAttachmentContentUri = { + photoPickerSourceContentUriByAttachmentContentUri + }, + contentPadding = contentPadding, + ) + } + + protected fun setReviewContent( + attachments: () -> ImmutableList, + conversationTitle: () -> String? = { CONVERSATION_TITLE }, + initiallyReviewedContentUri: () -> String? = { null }, + reviewRequestSequence: () -> Int = { 0 }, + isSendActionEnabled: () -> Boolean = { true }, + photoPickerSourceContentUriByAttachmentContentUri: + () -> ImmutableMap = { persistentMapOf() }, + contentPadding: PaddingValues = PaddingValues(), + ) { + composeTestRule.setContent { + ReviewContent { + ConversationMediaReviewScene( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + attachments = attachments(), + conversationTitle = conversationTitle(), + initiallyReviewedContentUri = initiallyReviewedContentUri(), + reviewRequestSequence = reviewRequestSequence(), + isSendActionEnabled = isSendActionEnabled(), + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri(), + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onAddMoreClick, + onClearReview = onClearReview, + onCloseClick = onCloseClick, + onSendClick = onSendClick, + ) + } + } + } + + @Composable + protected fun ReviewContent(content: @Composable () -> Unit) { + AppTheme(content = content) + } + + protected fun captionTextField(): SemanticsNodeInteraction { + return composeTestRule.onNode(hasSetTextAction()) + } + + protected fun clickReviewPageCenter(contentUri: String = IMAGE_CONTENT_URI) { + composeTestRule + .onNodeWithTag( + testTag = conversationMediaReviewPreviewTestTag(contentUri = contentUri), + ) + .performTouchInput { + click(position = center) + } + } + + protected fun awaitText(text: String) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule + .onAllNodesWithText(text = text) + .fetchSemanticsNodes() + .isNotEmpty() + } + } + + protected fun imageAttachment( + key: String = IMAGE_KEY, + contentUri: String = IMAGE_CONTENT_URI, + captionText: String = IMAGE_CAPTION, + width: Int? = 640, + height: Int? = 480, + ): ReviewImageAttachment { + return ReviewImageAttachment( + key = key, + contentType = IMAGE_CONTENT_TYPE, + contentUri = contentUri, + captionText = captionText, + width = width, + height = height, + ) + } + + protected fun videoAttachment( + key: String = VIDEO_KEY, + contentUri: String = VIDEO_CONTENT_URI, + captionText: String = VIDEO_CAPTION, + width: Int? = 1280, + height: Int? = 720, + ): ReviewVideoAttachment { + return ReviewVideoAttachment( + key = key, + contentType = VIDEO_CONTENT_TYPE, + contentUri = contentUri, + captionText = captionText, + width = width, + height = height, + ) + } + + protected fun threeAttachments(): ImmutableList { + return persistentListOf( + imageAttachment( + key = FIRST_IMAGE_KEY, + contentUri = FIRST_IMAGE_CONTENT_URI, + captionText = FIRST_IMAGE_CAPTION, + ), + videoAttachment( + key = VIDEO_KEY, + contentUri = VIDEO_CONTENT_URI, + captionText = VIDEO_CAPTION, + ), + imageAttachment( + key = LAST_IMAGE_KEY, + contentUri = LAST_IMAGE_CONTENT_URI, + captionText = LAST_IMAGE_CAPTION, + ), + ) + } + + protected companion object { + const val CONVERSATION_TITLE = "Weekend plan" + const val IMAGE_KEY = "image-1" + const val IMAGE_CONTENT_URI = "content://media/review/image/1" + const val IMAGE_CONTENT_TYPE = "image/jpeg" + const val IMAGE_CAPTION = "Image caption" + const val FIRST_IMAGE_KEY = "image-first" + const val FIRST_IMAGE_CONTENT_URI = "content://media/review/image/first" + const val FIRST_IMAGE_CAPTION = "First image caption" + const val LAST_IMAGE_KEY = "image-last" + const val LAST_IMAGE_CONTENT_URI = "content://media/review/image/last" + const val LAST_IMAGE_CAPTION = "Last image caption" + const val VIDEO_KEY = "video-1" + const val VIDEO_CONTENT_URI = "content://media/review/video/1" + const val VIDEO_CONTENT_TYPE = "video/mp4" + const val VIDEO_CAPTION = "Video caption" + const val PHOTO_PICKER_SOURCE_URI = "content://photo-picker/source/1" + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/BaseConversationInlineAudioAttachmentRowTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/BaseConversationInlineAudioAttachmentRowTest.kt new file mode 100644 index 000000000..bd1ecdac0 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/BaseConversationInlineAudioAttachmentRowTest.kt @@ -0,0 +1,47 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.android.messaging.ui.core.AppTheme +import org.junit.Rule + +internal abstract class BaseConversationInlineAudioAttachmentRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + protected fun setContent( + isPlaying: Boolean, + progress: Float, + ) { + setContent( + isPlaying = { isPlaying }, + progress = { progress }, + ) + } + + protected fun setContent( + isPlaying: () -> Boolean, + progress: () -> Float, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + ConversationInlineAudioAttachmentRowContent( + colors = rememberConversationInlineAudioAttachmentColors( + isIncoming = true, + isSelectionMode = false, + useStandaloneAudioAttachmentBackground = false, + ), + isSelectionMode = false, + isPlaying = isPlaying(), + title = "Audio attachment", + durationLabel = "00:18", + progress = progress(), + onClick = onClick, + onLongClick = onLongClick, + ) + } + } + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt new file mode 100644 index 000000000..6a8856836 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt @@ -0,0 +1,259 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.Dp +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationGenericInlineAttachmentRow +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationInlineAttachmentRow +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationInlineAudioAttachmentRowContent +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationMessageAttachments +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationStandaloneVisualAttachment +import com.android.messaging.ui.conversation.messages.ui.attachment.rememberConversationInlineAudioAttachmentColors +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseConversationMessageAttachmentRenderingTest { + + @get:Rule + val composeTestRule = createComposeRule() + + protected val onAttachmentClick = mockk<(String, String) -> Unit>(relaxed = true) + protected val onExternalUriClick = mockk<(String) -> Unit>(relaxed = true) + protected val onMessageLongClick = mockk<() -> Unit>(relaxed = true) + protected val onAudioRowClick = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUp() { + clearAllMocks() + } + + protected fun setMessageAttachmentsContent( + attachmentSections: ConversationAttachmentSections, + hasTextAboveVisualAttachments: Boolean = false, + hasTextBelowVisualAttachments: Boolean = false, + isIncoming: Boolean = true, + isSelectionMode: Boolean = false, + useStandaloneAudioAttachmentBackground: Boolean = false, + width: Dp = ATTACHMENT_WIDTH, + ) { + composeTestRule.setContent { + AppTheme { + ConversationMessageAttachments( + modifier = Modifier + .width(width = width) + .testTag(tag = MESSAGE_ATTACHMENTS_TAG), + attachmentSections = attachmentSections, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = useStandaloneAudioAttachmentBackground, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + } + + protected fun setStandaloneVisualAttachmentContent( + attachment: ConversationMessageAttachment, + hasTextAboveVisualAttachments: Boolean = false, + hasTextBelowVisualAttachments: Boolean = false, + width: Dp = ATTACHMENT_WIDTH, + ) { + composeTestRule.setContent { + AppTheme { + Box( + modifier = Modifier + .width(width = width) + .testTag(tag = STANDALONE_VISUAL_TAG), + ) { + ConversationStandaloneVisualAttachment( + attachment = attachment, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + } + } + + protected fun setGenericInlineAttachmentContent( + attachment: ConversationInlineAttachment.File, + width: Dp = ATTACHMENT_WIDTH, + ) { + composeTestRule.setContent { + AppTheme { + Box( + modifier = Modifier + .width(width = width) + .testTag(tag = INLINE_ROW_TAG), + ) { + ConversationGenericInlineAttachmentRow( + attachment = attachment, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onMessageLongClick, + ) + } + } + } + } + + protected fun setInlineAttachmentRowContent( + attachment: ConversationInlineAttachment, + isIncoming: Boolean = true, + isSelectionMode: Boolean = false, + useStandaloneAudioAttachmentBackground: Boolean = false, + width: Dp = ATTACHMENT_WIDTH, + ) { + composeTestRule.setContent { + AppTheme { + Box( + modifier = Modifier + .width(width = width) + .testTag(tag = INLINE_ROW_TAG), + ) { + ConversationInlineAttachmentRow( + attachment = attachment, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = + useStandaloneAudioAttachmentBackground, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onMessageLongClick, + ) + } + } + } + } + + protected fun setAudioRowContent( + isSelectionMode: Boolean, + isIncoming: Boolean = true, + useStandaloneAudioAttachmentBackground: Boolean = false, + width: Dp = ATTACHMENT_WIDTH, + ) { + composeTestRule.setContent { + AppTheme { + Box( + modifier = Modifier + .width(width = width) + .testTag(tag = INLINE_ROW_TAG), + ) { + ConversationInlineAudioAttachmentRowContent( + colors = rememberConversationInlineAudioAttachmentColors( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = + useStandaloneAudioAttachmentBackground, + ), + isSelectionMode = isSelectionMode, + isPlaying = false, + title = AUDIO_TITLE, + durationLabel = AUDIO_DURATION, + progress = 0f, + onClick = onAudioRowClick, + onLongClick = onMessageLongClick, + ) + } + } + } + } + + protected fun clickMessageAttachmentsAt( + xFraction: Float = 0.5f, + yFraction: Float = 0.5f, + ) { + composeTestRule + .onNodeWithTag(testTag = MESSAGE_ATTACHMENTS_TAG) + .performFractionalClick( + xFraction = xFraction, + yFraction = yFraction, + ) + } + + protected fun longClickMessageAttachmentsAt( + xFraction: Float = 0.5f, + yFraction: Float = 0.5f, + ) { + composeTestRule + .onNodeWithTag(testTag = MESSAGE_ATTACHMENTS_TAG) + .performFractionalLongClick( + xFraction = xFraction, + yFraction = yFraction, + ) + } + + protected fun clickInlineRow() { + composeTestRule + .onNodeWithTag(testTag = INLINE_ROW_TAG) + .performFractionalClick() + } + + protected fun longClickInlineRow() { + composeTestRule + .onNodeWithTag(testTag = INLINE_ROW_TAG) + .performFractionalLongClick() + } + + protected fun clickStandaloneVisual() { + composeTestRule + .onNodeWithTag(testTag = STANDALONE_VISUAL_TAG) + .performFractionalClick() + } + + protected fun longClickStandaloneVisual() { + composeTestRule + .onNodeWithTag(testTag = STANDALONE_VISUAL_TAG) + .performFractionalLongClick() + } + + private fun SemanticsNodeInteraction.performFractionalClick( + xFraction: Float = 0.5f, + yFraction: Float = 0.5f, + ) { + performTouchInput { + click( + position = Offset( + x = width * xFraction, + y = height * yFraction, + ), + ) + } + } + + private fun SemanticsNodeInteraction.performFractionalLongClick( + xFraction: Float = 0.5f, + yFraction: Float = 0.5f, + ) { + performTouchInput { + longClick( + position = Offset( + x = width * xFraction, + y = height * yFraction, + ), + ) + } + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentRenderingFixtures.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentRenderingFixtures.kt new file mode 100644 index 000000000..9ec44fddd --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentRenderingFixtures.kt @@ -0,0 +1,275 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import android.net.Uri +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import kotlinx.collections.immutable.persistentListOf + +internal const val MESSAGE_ATTACHMENTS_TAG = "message-attachments-under-test" +internal const val INLINE_ROW_TAG = "inline-row-under-test" +internal const val STANDALONE_VISUAL_TAG = "standalone-visual-under-test" +internal const val IMAGE_KEY = "image-1" +internal const val IMAGE_CONTENT_URI = "content://mms/part/image-1" +internal const val IMAGE_CONTENT_TYPE = "image/jpeg" +internal const val SECOND_IMAGE_KEY = "image-2" +internal const val SECOND_IMAGE_CONTENT_URI = "content://mms/part/image-2" +internal const val THIRD_IMAGE_KEY = "image-3" +internal const val THIRD_IMAGE_CONTENT_URI = "content://mms/part/image-3" +internal const val VIDEO_KEY = "video-1" +internal const val VIDEO_CONTENT_URI = "content://mms/part/video-1" +internal const val VIDEO_CONTENT_TYPE = "video/mp4" +internal const val AUDIO_KEY = "audio-1" +internal const val AUDIO_CONTENT_URI = "content://mms/part/audio-1" +internal const val AUDIO_CONTENT_TYPE = "audio/x-wav" +internal const val AUDIO_TITLE = "Voice message" +internal const val AUDIO_DURATION = "00:18" +internal const val FILE_KEY = "file-1" +internal const val FILE_CONTENT_URI = "content://mms/part/file-1" +internal const val FILE_CONTENT_TYPE = "application/pdf" +internal const val FILE_TITLE = "application/pdf" +internal const val UNSUPPORTED_KEY = "unsupported-1" +internal const val UNSUPPORTED_CONTENT_URI = "content://mms/part/unsupported-1" +internal const val UNSUPPORTED_CONTENT_TYPE = "application/octet-stream" +internal const val VCARD_KEY = "vcard-1" +internal const val VCARD_CONTENT_URI = "content://mms/part/vcard-1" +internal const val VCARD_CONTENT_TYPE = "text/x-vCard" +internal const val VCARD_TITLE = "Sam Rivera" +internal const val VCARD_SUBTITLE = "sam@example.com" +internal const val YOUTUBE_KEY = "youtube-1" +internal const val YOUTUBE_SOURCE_URI = "https://www.youtube.com/watch?v=abc" +internal const val YOUTUBE_THUMBNAIL_URI = "https://img.youtube.com/vi/abc/0.jpg" +internal val ATTACHMENT_WIDTH = 240.dp + +internal fun emptySections(): ConversationAttachmentSections { + return ConversationAttachmentSections( + galleryVisualAttachments = persistentListOf(), + trailingItems = persistentListOf(), + ) +} + +internal fun gallerySections( + vararg attachments: ConversationMessageAttachment, +): ConversationAttachmentSections { + return ConversationAttachmentSections( + galleryVisualAttachments = persistentListOf(*attachments), + trailingItems = persistentListOf(), + ) +} + +internal fun trailingSections( + vararg items: ConversationAttachmentItem, +): ConversationAttachmentSections { + return ConversationAttachmentSections( + galleryVisualAttachments = persistentListOf(), + trailingItems = persistentListOf(*items), + ) +} + +internal fun imageAttachment( + key: String = IMAGE_KEY, + contentUri: String = IMAGE_CONTENT_URI, + width: Int = 640, + height: Int = 480, +): ConversationMessageAttachment.Media { + return ConversationMessageAttachment.Media( + key = key, + part = ConversationMessagePartUiModel.Attachment.Image( + text = null, + contentType = IMAGE_CONTENT_TYPE, + contentUri = Uri.parse(contentUri), + width = width, + height = height, + ), + ) +} + +internal fun videoAttachment( + key: String = VIDEO_KEY, + contentUri: String = VIDEO_CONTENT_URI, + width: Int = 1280, + height: Int = 720, +): ConversationMessageAttachment.Media { + return ConversationMessageAttachment.Media( + key = key, + part = ConversationMessagePartUiModel.Attachment.Video( + text = null, + contentType = VIDEO_CONTENT_TYPE, + contentUri = Uri.parse(contentUri), + width = width, + height = height, + ), + ) +} + +internal fun audioAttachment( + key: String = AUDIO_KEY, + contentUri: String = AUDIO_CONTENT_URI, +): ConversationMessageAttachment.Media { + return ConversationMessageAttachment.Media( + key = key, + part = ConversationMessagePartUiModel.Attachment.Audio( + text = null, + contentType = AUDIO_CONTENT_TYPE, + contentUri = Uri.parse(contentUri), + width = 0, + height = 0, + ), + ) +} + +internal fun fileAttachment( + key: String = FILE_KEY, + contentUri: String? = FILE_CONTENT_URI, + contentType: String = FILE_CONTENT_TYPE, +): ConversationMessageAttachment.Media { + return ConversationMessageAttachment.Media( + key = key, + part = filePart( + contentUri = contentUri, + contentType = contentType, + ), + ) +} + +internal fun unsupportedAttachment( + key: String = UNSUPPORTED_KEY, + contentUri: String? = UNSUPPORTED_CONTENT_URI, + contentType: String = UNSUPPORTED_CONTENT_TYPE, +): ConversationMessageAttachment.Unsupported { + return ConversationMessageAttachment.Unsupported( + key = key, + part = filePart( + contentUri = contentUri, + contentType = contentType, + ), + ) +} + +internal fun youTubeAttachment( + key: String = YOUTUBE_KEY, + sourceUrl: String = YOUTUBE_SOURCE_URI, + thumbnailUrl: String = YOUTUBE_THUMBNAIL_URI, +): ConversationMessageAttachment.YouTubePreview { + return ConversationMessageAttachment.YouTubePreview( + key = key, + sourceUrl = sourceUrl, + thumbnailUrl = thumbnailUrl, + ) +} + +internal fun standaloneVisualItem( + attachment: ConversationMessageAttachment, +): ConversationAttachmentItem.StandaloneVisual { + return ConversationAttachmentItem.StandaloneVisual( + key = attachment.key, + attachment = attachment, + ) +} + +internal fun inlineItem( + attachment: ConversationInlineAttachment, +): ConversationAttachmentItem.Inline { + return ConversationAttachmentItem.Inline( + key = attachment.key, + attachment = attachment, + ) +} + +internal fun audioInlineAttachment( + key: String = AUDIO_KEY, + contentUri: String = AUDIO_CONTENT_URI, + titleText: String? = AUDIO_TITLE, +): ConversationInlineAttachment.Audio { + return ConversationInlineAttachment.Audio( + key = key, + contentUri = contentUri, + openAction = ConversationAttachmentOpenAction.OpenContent( + contentType = AUDIO_CONTENT_TYPE, + contentUri = contentUri, + ), + titleText = titleText, + titleTextResId = R.string.audio_attachment_content_description, + ) +} + +internal fun fileInlineAttachment( + key: String = FILE_KEY, + openAction: ConversationAttachmentOpenAction? = ConversationAttachmentOpenAction.OpenContent( + contentType = FILE_CONTENT_TYPE, + contentUri = FILE_CONTENT_URI, + ), + subtitleTextResId: Int? = null, + titleText: String? = FILE_TITLE, + titleTextResId: Int? = R.string.notification_file, +): ConversationInlineAttachment.File { + return ConversationInlineAttachment.File( + key = key, + openAction = openAction, + subtitleTextResId = subtitleTextResId, + titleText = titleText, + titleTextResId = titleTextResId, + ) +} + +internal fun vCardInlineAttachment( + key: String = VCARD_KEY, + openAction: ConversationAttachmentOpenAction? = ConversationAttachmentOpenAction.OpenContent( + contentType = VCARD_CONTENT_TYPE, + contentUri = VCARD_CONTENT_URI, + ), + titleText: String? = VCARD_TITLE, + subtitleText: String? = VCARD_SUBTITLE, +): ConversationInlineAttachment.VCard { + return ConversationInlineAttachment.VCard( + key = key, + contentUri = VCARD_CONTENT_URI, + openAction = openAction, + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + titleText = titleText, + titleTextResId = R.string.notification_vcard, + subtitleText = subtitleText, + subtitleTextResId = R.string.vcard_tap_hint, + ) +} + +internal fun vCardMediaAttachment( + key: String = VCARD_KEY, +): ConversationMessageAttachment.Media { + return ConversationMessageAttachment.Media( + key = key, + part = ConversationMessagePartUiModel.Attachment.VCard( + text = null, + contentType = VCARD_CONTENT_TYPE, + contentUri = Uri.parse(VCARD_CONTENT_URI), + width = 0, + height = 0, + vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = VCARD_TITLE, + subtitleText = VCARD_SUBTITLE, + ), + ), + ) +} + +private fun filePart( + contentUri: String?, + contentType: String, +): ConversationMessagePartUiModel.Attachment.File { + return ConversationMessagePartUiModel.Attachment.File( + text = null, + contentType = contentType, + contentUri = contentUri?.let(Uri::parse), + width = 0, + height = 0, + ) +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt new file mode 100644 index 000000000..8cb1bde64 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt @@ -0,0 +1,237 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import android.net.Uri +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag +import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import com.android.messaging.ui.conversation.messages.ui.message.ConversationMessage +import com.android.messaging.ui.conversation.messages.ui.message.ConversationMessageAvatar +import com.android.messaging.ui.conversation.messages.ui.message.ConversationMmsDownloadBody +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseConversationMessageRenderingTest { + + @get:Rule + val composeTestRule = createComposeRule() + + protected val onAttachmentClick = mockk<(String, String) -> Unit>(relaxed = true) + protected val onAvatarClick = mockk<() -> Unit>(relaxed = true) + protected val onDownloadClick = mockk<() -> Unit>(relaxed = true) + protected val onExternalUriClick = mockk<(String) -> Unit>(relaxed = true) + protected val onMessageClick = mockk<() -> Unit>(relaxed = true) + protected val onMessageLongClick = mockk<() -> Unit>(relaxed = true) + protected val onResendClick = mockk<() -> Unit>(relaxed = true) + protected val onSimSelectorClick = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUp() { + clearAllMocks() + } + + protected fun setConversationMessageContent( + message: ConversationMessageUiModel, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + showIncomingParticipantIdentity: Boolean = true, + simDisplayName: String? = null, + ) { + composeTestRule.setContent { + AppTheme { + ConversationMessage( + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + simDisplayName = simDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onAvatarClick, + onMessageDownloadClick = onDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onResendClick, + onSimSelectorClick = onSimSelectorClick, + ) + } + } + } + + protected fun setAvatarContent(message: ConversationMessageUiModel) { + composeTestRule.setContent { + AppTheme { + ConversationMessageAvatar( + modifier = Modifier.testTag(tag = AVATAR_TAG), + message = message, + onClick = onAvatarClick, + onLongClick = onMessageLongClick, + ) + } + } + } + + protected fun setMmsDownloadBodyContent( + download: MmsDownloadUiModel, + canDownloadMessage: Boolean, + isSelected: Boolean = false, + simDisplayName: String? = null, + ) { + composeTestRule.setContent { + AppTheme { + ConversationMmsDownloadBody( + download = download, + canDownloadMessage = canDownloadMessage, + isSelected = isSelected, + contentColor = Color.Black, + simDisplayName = simDisplayName, + ) + } + } + } + + protected fun message( + messageId: String = DEFAULT_MESSAGE_ID, + text: String? = DEFAULT_BODY_TEXT, + parts: ImmutableList = persistentListOf(), + status: ConversationMessageUiModel.Status = + ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming: Boolean = false, + senderDisplayName: String? = null, + senderAvatarUri: Uri? = null, + senderContactId: Long = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderContactLookupKey: String? = null, + senderNormalizedDestination: String? = null, + senderParticipantId: String? = null, + canClusterWithPrevious: Boolean = false, + canClusterWithNext: Boolean = false, + canDownloadMessage: Boolean = false, + canResendMessage: Boolean = false, + canSaveAttachments: Boolean = false, + mmsDownload: MmsDownloadUiModel? = null, + mmsSubject: String? = null, + protocol: ConversationMessageUiModel.Protocol = ConversationMessageUiModel.Protocol.SMS, + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = messageId, + conversationId = CONVERSATION_ID, + text = text, + parts = parts, + sentTimestamp = TIMESTAMP, + receivedTimestamp = TIMESTAMP, + displayTimestamp = TIMESTAMP, + status = status, + isIncoming = isIncoming, + senderDisplayName = senderDisplayName, + senderAvatarUri = senderAvatarUri, + senderContactId = senderContactId, + senderContactLookupKey = senderContactLookupKey, + senderNormalizedDestination = senderNormalizedDestination, + senderParticipantId = senderParticipantId, + selfParticipantId = SELF_PARTICIPANT_ID, + canClusterWithPrevious = canClusterWithPrevious, + canClusterWithNext = canClusterWithNext, + canCopyMessageToClipboard = !isIncoming, + canDownloadMessage = canDownloadMessage, + canForwardMessage = true, + canResendMessage = canResendMessage, + canSaveAttachments = canSaveAttachments, + mmsDownload = mmsDownload, + mmsSubject = mmsSubject, + protocol = protocol, + ) + } + + protected fun mmsDownload( + state: MmsDownloadUiModel.State = MmsDownloadUiModel.State.AwaitingManualDownload, + sizeBytes: Long = MMS_SIZE_BYTES, + expiryTimestamp: Long = MMS_EXPIRY_TIMESTAMP, + ): MmsDownloadUiModel { + return MmsDownloadUiModel( + state = state, + sizeBytes = sizeBytes, + expiryTimestamp = expiryTimestamp, + ) + } + + protected fun imagePart( + text: String? = null, + contentType: String = IMAGE_CONTENT_TYPE, + contentUri: String = IMAGE_CONTENT_URI, + width: Int = 640, + height: Int = 480, + ): ConversationMessagePartUiModel.Attachment.Image { + return ConversationMessagePartUiModel.Attachment.Image( + text = text, + contentType = contentType, + contentUri = Uri.parse(contentUri), + width = width, + height = height, + ) + } + + protected fun clickBubble(messageId: String = DEFAULT_MESSAGE_ID) { + composeTestRule + .onNodeWithTag( + testTag = conversationMessageBubbleTestTag(messageId = messageId), + ) + .performClick() + } + + protected fun longClickBubble(messageId: String = DEFAULT_MESSAGE_ID) { + composeTestRule + .onNodeWithTag( + testTag = conversationMessageBubbleTestTag(messageId = messageId), + ) + .performSemanticsAction(SemanticsActions.OnLongClick) + } + + protected fun clickSelectionRow(messageId: String = DEFAULT_MESSAGE_ID) { + composeTestRule + .onNodeWithTag( + testTag = conversationMessageSelectionRowTestTag(messageId = messageId), + ) + .performClick() + } + + protected fun longClickAvatar() { + composeTestRule + .onNodeWithTag(testTag = AVATAR_TAG) + .performSemanticsAction(SemanticsActions.OnLongClick) + } + + protected fun clickAvatar() { + composeTestRule + .onNodeWithTag(testTag = AVATAR_TAG) + .performClick() + } + + protected companion object { + protected const val AVATAR_TAG = "conversation-message-avatar-under-test" + protected const val DEFAULT_BODY_TEXT = "Message body" + protected const val DEFAULT_MESSAGE_ID = "message-1" + protected const val IMAGE_CONTENT_TYPE = "image/jpeg" + protected const val IMAGE_CONTENT_URI = "content://mms/part/image-1" + protected const val MMS_EXPIRY_TIMESTAMP = 1_700_003_600_000L + protected const val MMS_SIZE_BYTES = 1_572_864L + protected const val SELF_PARTICIPANT_ID = "self-1" + protected const val SIM_DISPLAY_NAME = "Work SIM" + protected const val TIMESTAMP = 1_700_000_000_000L + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/BaseRecipientSelectionContactRowTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/BaseRecipientSelectionContactRowTest.kt new file mode 100644 index 000000000..f8e82c916 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/BaseRecipientSelectionContactRowTest.kt @@ -0,0 +1,129 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.unit.dp +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.recipientpicker.component.MultiDestinationContactRow +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContactRow +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.component.recipientSelectionContactRowShape +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.selection.OnRecipientDestinationAction +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionStrings +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseRecipientSelectionContactRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + protected val onContentDestinationClick = mockk(relaxed = true) + protected val onContentDestinationLongClick = mockk( + relaxed = true, + ) + protected val onLoadMore = mockk<() -> Unit>(relaxed = true) + protected val onPrimaryActionClick = mockk<() -> Unit>(relaxed = true) + protected val onRowDestinationClick = mockk<(String) -> Unit>(relaxed = true) + protected val onRowDestinationLongClick = mockk<(String) -> Unit>(relaxed = true) + + @Before + fun setUpBaseRecipientSelectionContactRowTest() { + clearAllMocks() + } + + protected fun setMultiDestinationRowContent( + item: RecipientPickerListItem.Contact = multiDestinationContactItem(), + enabled: Boolean = true, + selectedDestinations: ImmutableSet = persistentSetOf(), + rowDecorators: RecipientSelectionRowDecorators = defaultRowDecorators(), + onDestinationLongClick: ((String) -> Unit)? = onRowDestinationLongClick, + ) { + composeTestRule.setContent { + AppTheme { + MultiDestinationContactRow( + item = item, + enabled = enabled, + selectedDestinations = selectedDestinations, + onDestinationClick = onRowDestinationClick, + onDestinationLongClick = onDestinationLongClick, + shape = RoundedCornerShape(size = 18.dp), + rowDecorators = rowDecorators, + ) + } + } + } + + protected fun setContactRowContent( + item: RecipientPickerListItem, + enabled: Boolean = true, + selectedDestinations: ImmutableSet = persistentSetOf(), + rowDecorators: RecipientSelectionRowDecorators = defaultRowDecorators(), + onDestinationLongClick: ((String) -> Unit)? = onRowDestinationLongClick, + ) { + composeTestRule.setContent { + AppTheme { + RecipientSelectionContactRow( + item = item, + enabled = enabled, + selectedDestinations = selectedDestinations, + onDestinationClick = onRowDestinationClick, + onDestinationLongClick = onDestinationLongClick, + shape = recipientSelectionContactRowShape(index = 0, totalCount = 1), + rowDecorators = rowDecorators, + ) + } + } + } + + protected fun setSelectionContent( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators = defaultRowDecorators(), + onRecipientDestinationLongClick: OnRecipientDestinationAction? = + onContentDestinationLongClick, + ) { + composeTestRule.setContent { + AppTheme { + RecipientSelectionContent( + uiState = uiState, + strings = RecipientSelectionStrings( + queryPrefixText = targetContext.getString(R.string.to_address_label), + queryPlaceholderText = targetContext.getString( + R.string.new_chat_query_hint, + ), + ), + rowDecorators = rowDecorators, + onRecipientDestinationClick = onContentDestinationClick, + onLoadMore = onLoadMore, + onPrimaryActionClick = onPrimaryActionClick, + onQueryChanged = {}, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, + ) + } + } + } + + protected fun defaultRowDecorators( + showTrailingIndicator: (RecipientPickerListItem, String) -> Boolean = { _, _ -> false }, + ): RecipientSelectionRowDecorators { + return RecipientSelectionRowDecorators( + recipientRowTestTag = { item -> + recipientRowTestTag(item = item) + }, + destinationRowTestTag = { item, destination -> + destinationRowTestTag(item = item, destination = destination) + }, + showRecipientTrailingIndicator = showTrailingIndicator, + trailingIndicatorTestTag = TRAILING_INDICATOR_TEST_TAG, + ) + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowFixtures.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowFixtures.kt new file mode 100644 index 000000000..dbbd801e4 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowFixtures.kt @@ -0,0 +1,167 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone +import com.android.messaging.ui.contact.model.ContactDestinationUiModel +import com.android.messaging.ui.contact.model.ContactUiModel +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import kotlinx.collections.immutable.persistentListOf + +internal const val CONTACT_DISPLAY_NAME = "Ada Lovelace" +internal const val CONTACT_ID = 42L +internal const val EMPTY_CONTACT_DISPLAY_NAME = "No Destination" +internal const val HOME_PHONE_DESTINATION = "+1 555 0300" +internal const val HOME_PHONE_NORMALIZED_DESTINATION = "+15550300" +internal const val MOBILE_DESTINATION = "+1 555 0100" +internal const val MOBILE_NORMALIZED_DESTINATION = "+15550100" +internal const val RECIPIENT_DESTINATION_ROW_TEST_TAG_PREFIX = "recipient_destination_row_" +internal const val RECIPIENT_ROW_TEST_TAG_PREFIX = "recipient_row_" +internal const val SYNTHETIC_PHONE_DESTINATION = "+1 555 7777" +internal const val SYNTHETIC_PHONE_NORMALIZED_DESTINATION = "+15557777" +internal const val TRAILING_INDICATOR_TEST_TAG = "recipient_trailing_indicator" +internal const val WORK_EMAIL_DESTINATION = "ada@example.com" +internal const val WORK_EMAIL_NORMALIZED_DESTINATION = "ada@example.com" + +internal fun multiDestinationContactItem(): RecipientPickerListItem.Contact { + return RecipientPickerListItem.Contact( + contact = ContactUiModel( + id = CONTACT_ID, + lookupKey = "lookup-$CONTACT_ID", + displayName = CONTACT_DISPLAY_NAME, + photoUri = null, + destinations = persistentListOf( + mobileDestination(), + workEmailDestination(), + homePhoneDestination(), + ), + ), + ) +} + +internal fun contactItem( + id: Long = CONTACT_ID, + displayName: String = CONTACT_DISPLAY_NAME, + destination: String = MOBILE_DESTINATION, + normalizedDestination: String = MOBILE_NORMALIZED_DESTINATION, + dataId: Long = id, + kind: ContactDestinationUiModel.Kind = ContactDestinationUiModel.Kind.PHONE, + type: Int = Phone.TYPE_MOBILE, +): RecipientPickerListItem.Contact { + return RecipientPickerListItem.Contact( + contact = ContactUiModel( + id = id, + lookupKey = "lookup-$id", + displayName = displayName, + photoUri = null, + destinations = persistentListOf( + ContactDestinationUiModel( + dataId = dataId, + contactId = id, + value = destination, + normalizedValue = normalizedDestination, + displayValue = destination, + kind = kind, + type = type, + customLabel = null, + isPrimary = true, + isSuperPrimary = true, + ), + ), + ), + ) +} + +internal fun singleDestinationContactItem(): RecipientPickerListItem.Contact { + return contactItem() +} + +internal fun contactWithoutDestinations(): RecipientPickerListItem.Contact { + return RecipientPickerListItem.Contact( + contact = ContactUiModel( + id = CONTACT_ID, + lookupKey = "lookup-$CONTACT_ID", + displayName = EMPTY_CONTACT_DISPLAY_NAME, + photoUri = null, + destinations = persistentListOf(), + ), + ) +} + +internal fun syntheticPhoneItem(): RecipientPickerListItem.SyntheticPhone { + return RecipientPickerListItem.SyntheticPhone( + id = "synthetic:$SYNTHETIC_PHONE_NORMALIZED_DESTINATION", + rawQuery = SYNTHETIC_PHONE_DESTINATION, + destination = SYNTHETIC_PHONE_DESTINATION, + normalizedDestination = SYNTHETIC_PHONE_NORMALIZED_DESTINATION, + ) +} + +internal fun selectedRecipient( + destination: String = MOBILE_NORMALIZED_DESTINATION, + label: String = CONTACT_DISPLAY_NAME, + displayDestination: String = MOBILE_DESTINATION, +): SelectedRecipient { + return SelectedRecipient( + destination = destination, + label = label, + displayDestination = displayDestination, + photoUri = null, + ) +} + +internal fun mobileDestination(): ContactDestinationUiModel { + return ContactDestinationUiModel( + dataId = 101L, + contactId = CONTACT_ID, + value = MOBILE_DESTINATION, + normalizedValue = MOBILE_NORMALIZED_DESTINATION, + displayValue = MOBILE_DESTINATION, + kind = ContactDestinationUiModel.Kind.PHONE, + type = Phone.TYPE_MOBILE, + customLabel = null, + isPrimary = true, + isSuperPrimary = true, + ) +} + +internal fun workEmailDestination(): ContactDestinationUiModel { + return ContactDestinationUiModel( + dataId = 102L, + contactId = CONTACT_ID, + value = WORK_EMAIL_DESTINATION, + normalizedValue = WORK_EMAIL_NORMALIZED_DESTINATION, + displayValue = WORK_EMAIL_DESTINATION, + kind = ContactDestinationUiModel.Kind.EMAIL, + type = Email.TYPE_WORK, + customLabel = null, + isPrimary = false, + isSuperPrimary = false, + ) +} + +internal fun homePhoneDestination(): ContactDestinationUiModel { + return ContactDestinationUiModel( + dataId = 103L, + contactId = CONTACT_ID, + value = HOME_PHONE_DESTINATION, + normalizedValue = HOME_PHONE_NORMALIZED_DESTINATION, + displayValue = HOME_PHONE_DESTINATION, + kind = ContactDestinationUiModel.Kind.PHONE, + type = Phone.TYPE_HOME, + customLabel = null, + isPrimary = false, + isSuperPrimary = false, + ) +} + +internal fun recipientRowTestTag(item: RecipientPickerListItem): String { + return "$RECIPIENT_ROW_TEST_TAG_PREFIX${item.id}" +} + +internal fun destinationRowTestTag( + item: RecipientPickerListItem, + destination: String, +): String { + return "$RECIPIENT_DESTINATION_ROW_TEST_TAG_PREFIX${item.id}_$destination" +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt new file mode 100644 index 000000000..0e694279f --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt @@ -0,0 +1,204 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseConversationScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setUp() { + unmockkAll() + clearAllMocks() + } + + protected fun setContent( + screenModel: ConversationScreenModel, + conversationId: () -> String? = { CONVERSATION_ID }, + launchGeneration: () -> Int? = { 1 }, + cancelIncomingNotification: Boolean = true, + lifecycleOwner: LifecycleOwner? = null, + onAddPeopleClick: () -> Unit = {}, + pendingDraft: ConversationDraft? = null, + pendingSelfParticipantId: String? = null, + pendingStartupAttachment: ConversationEntryStartupAttachment? = null, + onPendingDraftConsumed: () -> Unit = {}, + onPendingSelfParticipantIdConsumed: () -> Unit = {}, + onPendingStartupAttachmentConsumed: () -> Unit = {}, + pendingScrollPosition: Int? = null, + onPendingScrollPositionConsumed: () -> Unit = {}, + ) { + composeTestRule.setContent { + val content: @Composable () -> Unit = { + ConversationScreen( + conversationId = conversationId(), + launchGeneration = launchGeneration(), + cancelIncomingNotification = cancelIncomingNotification, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = {}, + onNavigateBack = {}, + pendingDraft = pendingDraft, + pendingSelfParticipantId = pendingSelfParticipantId, + pendingStartupAttachment = pendingStartupAttachment, + onPendingDraftConsumed = onPendingDraftConsumed, + onPendingSelfParticipantIdConsumed = onPendingSelfParticipantIdConsumed, + onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, + screenModel = screenModel, + ) + } + AppTheme { + if (lifecycleOwner == null) { + content() + } else { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + content() + } + } + } + } + } + + protected fun createPresentUiState( + messages: List, + canAddPeople: Boolean = false, + canCall: Boolean = false, + canArchive: Boolean = false, + canUnarchive: Boolean = false, + canAddContact: Boolean = false, + canDeleteConversation: Boolean = false, + isDeleteConversationConfirmationVisible: Boolean = false, + otherParticipantPhoneNumber: String? = null, + otherParticipantContactLookupKey: String? = null, + isArchived: Boolean = false, + selection: ConversationMessageSelectionUiState = ConversationMessageSelectionUiState(), + ): ConversationScreenScaffoldUiState { + return ConversationScreenScaffoldUiState( + canAddPeople = canAddPeople, + canCall = canCall, + canArchive = canArchive, + canUnarchive = canUnarchive, + canAddContact = canAddContact, + canDeleteConversation = canDeleteConversation, + isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + metadata = ConversationMetadataUiState.Present( + title = "Weekend plan", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ), + participantCount = 2, + otherParticipantDisplayDestination = null, + otherParticipantPhoneNumber = otherParticipantPhoneNumber, + otherParticipantContactLookupKey = otherParticipantContactLookupKey, + isArchived = isArchived, + composerAvailability = ConversationComposerAvailability.Editable, + ), + messages = ConversationMessagesUiState.Present( + messages = messages.toPersistentList(), + ), + composer = ConversationComposerUiState( + isMessageFieldEnabled = true, + isAttachmentActionEnabled = true, + isSendEnabled = true, + ), + selection = selection, + ) + } + + protected fun createMessages( + count: Int, + latestMessageId: String, + latestMessageIncoming: Boolean, + messageIdPrefix: String = "message", + ): List { + val messages = mutableListOf() + for (index in 1..count) { + val messageId = "$messageIdPrefix-$index" + val isLatestMessage = messageId == latestMessageId + messages += ConversationMessageUiModel( + messageId = messageId, + conversationId = CONVERSATION_ID, + text = "Message $index", + parts = persistentListOf(), + sentTimestamp = index.toLong(), + receivedTimestamp = index.toLong(), + displayTimestamp = index.toLong(), + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = isLatestMessage && latestMessageIncoming, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = -1L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = "self-1", + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = !latestMessageIncoming, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } + + return messages + } + + protected fun createScreenModel(): ScreenModelHandle { + val effectsFlow = MutableSharedFlow() + val scaffoldUiStateFlow = MutableStateFlow(ConversationScreenScaffoldUiState()) + val mediaPickerOverlayUiStateFlow = MutableStateFlow( + ConversationMediaPickerOverlayUiState(), + ) + val model = mockk(relaxed = true) + + every { model.effects } returns effectsFlow + every { model.scaffoldUiState } returns scaffoldUiStateFlow + every { model.mediaPickerOverlayUiState } returns mediaPickerOverlayUiStateFlow + + return ScreenModelHandle( + model = model, + scaffoldUiStateFlow = scaffoldUiStateFlow, + ) + } + + protected class ScreenModelHandle( + val model: ConversationScreenModel, + val scaffoldUiStateFlow: MutableStateFlow, + ) +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/dialogs/BaseConversationScreenDialogsTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/dialogs/BaseConversationScreenDialogsTest.kt new file mode 100644 index 000000000..2409793b6 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/dialogs/BaseConversationScreenDialogsTest.kt @@ -0,0 +1,79 @@ +package com.android.messaging.ui.conversation.screen.dialogs + +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.screen.BaseConversationScreenTest +import com.android.messaging.ui.conversation.screen.ConversationScreenDialogs +import com.android.messaging.ui.conversation.screen.ConversationScreenModel +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState +import com.android.messaging.ui.core.AppTheme +import io.mockk.mockk + +internal abstract class BaseConversationScreenDialogsTest : BaseConversationScreenTest() { + + protected fun setDialogsContent( + uiState: ConversationScreenScaffoldUiState, + screenModel: ConversationScreenModel = mockk(relaxed = true), + ): ConversationScreenModel { + composeTestRule.setContent { + AppTheme { + ConversationScreenDialogs( + uiState = uiState, + screenModel = screenModel, + ) + } + } + return screenModel + } + + protected fun createDialogUiState( + attachmentLimitWarning: ConversationAttachmentLimitWarning? = null, + deleteConfirmation: ConversationMessageDeleteConfirmationUiState? = null, + isDeleteConversationConfirmationVisible: Boolean = false, + isSubjectDialogVisible: Boolean = false, + subjectText: String = "", + ): ConversationScreenScaffoldUiState { + return ConversationScreenScaffoldUiState( + attachmentLimitWarning = attachmentLimitWarning, + isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = isSubjectDialogVisible, + composer = ConversationComposerUiState( + subjectText = subjectText, + ), + selection = ConversationMessageSelectionUiState( + deleteConfirmation = deleteConfirmation, + ), + ) + } + + protected fun text(resourceId: Int): String { + return targetContext.getString(resourceId) + } + + protected fun quantityText( + resourceId: Int, + quantity: Int, + ): String { + return targetContext.resources.getQuantityString(resourceId, quantity, quantity) + } + + protected fun okText(): String { + return targetContext.getString(android.R.string.ok) + } + + protected fun cancelText(): String { + return targetContext.getString(android.R.string.cancel) + } + + protected fun deleteConversationTitle(): String { + return targetContext.resources.getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, + 1, + 1, + ) + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt new file mode 100644 index 000000000..4dc986469 --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt @@ -0,0 +1,167 @@ +package com.android.messaging.ui.conversation.screen.effects + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.screen.ConversationScreenEffects +import com.android.messaging.ui.conversation.screen.ConversationScreenModel +import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState +import com.android.messaging.ui.core.AppTheme +import io.mockk.CapturingSlot +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule + +internal abstract class BaseConversationScreenEffectsActionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + protected lateinit var effectsFlow: MutableSharedFlow + protected lateinit var screenModel: ConversationScreenModel + protected lateinit var snackbarHostState: SnackbarHostState + protected lateinit var hostBoundsState: MutableState + protected lateinit var defaultSmsRoleLauncher: ActivityResultLauncher + protected lateinit var defaultSmsRoleResultCallbackSlot: + CapturingSlot> + + private lateinit var activityResultRegistry: ActivityResultRegistry + private lateinit var activityResultRegistryOwner: ActivityResultRegistryOwner + + @Before + fun setUp() { + unmockkAll() + clearAllMocks() + + effectsFlow = MutableSharedFlow(extraBufferCapacity = EFFECT_BUFFER_CAPACITY) + screenModel = mockk(relaxed = true) + every { screenModel.effects } returns effectsFlow + every { screenModel.mediaPickerOverlayUiState } returns MutableStateFlow( + ConversationMediaPickerOverlayUiState(), + ) + every { screenModel.scaffoldUiState } returns MutableStateFlow( + ConversationScreenScaffoldUiState(), + ) + + defaultSmsRoleLauncher = mockk(relaxed = true) + defaultSmsRoleResultCallbackSlot = slot() + activityResultRegistry = mockk(relaxed = true) + activityResultRegistryOwner = mockk() + every { activityResultRegistryOwner.activityResultRegistry } returns activityResultRegistry + every { + activityResultRegistry.register( + any(), + any>(), + capture(defaultSmsRoleResultCallbackSlot), + ) + } returns defaultSmsRoleLauncher + } + + @After + fun tearDown() { + unmockkAll() + } + + protected fun setEffectsContent( + initialSnackbarMessage: String? = null, + initialHostBounds: ComposeRect? = ComposeRect( + left = 0f, + top = 0f, + right = 100f, + bottom = 100f, + ), + onNavigateBack: () -> Unit = {}, + ) { + snackbarHostState = SnackbarHostState() + hostBoundsState = mutableStateOf(initialHostBounds) + + composeTestRule.setContent { + CompositionLocalProvider( + LocalActivityResultRegistryOwner provides activityResultRegistryOwner, + ) { + AppTheme { + Box { + SnackbarHost(hostState = snackbarHostState) + if (initialSnackbarMessage != null) { + LaunchedEffect(initialSnackbarMessage) { + snackbarHostState.showSnackbar( + message = initialSnackbarMessage, + duration = SnackbarDuration.Indefinite, + ) + } + } + ConversationScreenEffects( + screenModel = screenModel, + snackbarHostState = snackbarHostState, + hostBoundsState = hostBoundsState, + onNavigateBack = onNavigateBack, + ) + } + } + } + } + waitForEffectsCollector() + } + + protected fun emitEffect(effect: ConversationScreenEffect) { + composeTestRule.runOnIdle { + assertTrue(effectsFlow.tryEmit(effect)) + } + composeTestRule.waitForIdle() + } + + protected fun waitForSnackbarMessage(message: String) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + snackbarHostState.currentSnackbarData?.visuals?.message == message + } + } + + @Suppress("SameParameterValue") + protected fun dispatchDefaultSmsRoleResult(resultCode: Int) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + defaultSmsRoleResultCallbackSlot.isCaptured + } + composeTestRule.runOnIdle { + defaultSmsRoleResultCallbackSlot.captured.onActivityResult( + ActivityResult(resultCode, null), + ) + } + } + + protected fun waitForEffectsCollector() { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + effectsFlow.subscriptionCount.value == 1 + } + } + + private companion object { + private const val EFFECT_BUFFER_CAPACITY = 16 + } +} diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/simselector/BaseConversationScreenSimSelectorIntegrationTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/simselector/BaseConversationScreenSimSelectorIntegrationTest.kt new file mode 100644 index 000000000..39fd8375d --- /dev/null +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/simselector/BaseConversationScreenSimSelectorIntegrationTest.kt @@ -0,0 +1,56 @@ +package com.android.messaging.ui.conversation.screen.simselector + +import androidx.compose.ui.test.onAllNodesWithTag +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.screen.BaseConversationScreenTest +import com.android.messaging.ui.conversation.testAttSubscription +import com.android.messaging.ui.conversation.testVerizonSubscription +import kotlinx.collections.immutable.persistentListOf + +internal abstract class BaseConversationScreenSimSelectorIntegrationTest : + BaseConversationScreenTest() { + + protected fun setSimSelectorContent( + screenModel: ScreenModelHandle, + simSelector: ConversationSimSelectorUiState, + ) { + val uiState = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + ) + screenModel.scaffoldUiStateFlow.value = uiState.copy( + composer = uiState.composer.copy( + simSelector = simSelector, + ), + ) + setContent(screenModel = screenModel.model) + } + + protected fun waitForSheetNodeCount(expectedCount: Int) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule + .onAllNodesWithTag(CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG) + .fetchSemanticsNodes() + .size == expectedCount + } + } + + protected fun createSingleSimSelector(): ConversationSimSelectorUiState { + return ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription), + selectedSubscription = testVerizonSubscription, + ) + } + + protected fun createMultiSimSelector(): ConversationSimSelectorUiState { + return ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testVerizonSubscription, + ) + } +} From 910e875b776ea25824ab809464f4d66f61ed4803 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:13:30 +0300 Subject: [PATCH 17/38] Cover app settings and utility logic --- .../AppSettingsRepositoryImplTest.kt | 156 +++++++++++++++++ .../AppSettingsUiStateMapperImplTest.kt | 54 ++++++ ...seSubscriptionSettingsUiStateMapperTest.kt | 93 +++++++++++ ...ptionSettingsUiStateMapperSelectionTest.kt | 135 +++++++++++++++ ...ngsUiStateMapperSubscriptionMappingTest.kt | 158 ++++++++++++++++++ .../android/messaging/util/ImageUtilsTest.kt | 93 +++++++++++ 6 files changed, 689 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/BaseSubscriptionSettingsUiStateMapperTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSelectionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSubscriptionMappingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/util/ImageUtilsTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt new file mode 100644 index 000000000..137d578ad --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt @@ -0,0 +1,156 @@ +package com.android.messaging.data.appsettings.repository.appsettingsrepository + +import android.content.Context +import android.content.res.Resources +import com.android.messaging.Factory +import com.android.messaging.R +import com.android.messaging.data.appsettings.model.AppBooleanPref +import com.android.messaging.data.appsettings.repository.AppSettingsRepositoryImpl +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.util.BugleGservices +import com.android.messaging.util.BuglePrefs +import com.android.messaging.util.PhoneUtils +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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class AppSettingsRepositoryImplTest { + + private lateinit var factory: Factory + private lateinit var appPrefs: BuglePrefs + private lateinit var bugleGservices: BugleGservices + private lateinit var context: Context + private lateinit var phoneUtils: PhoneUtils + private lateinit var resources: Resources + + @Before + fun setUp() { + factory = mockk() + appPrefs = mockk() + bugleGservices = mockk() + context = mockk() + phoneUtils = mockk() + resources = mockk() + + mockkStatic(Factory::class) + + every { Factory.get() } returns factory + every { factory.applicationPrefs } returns appPrefs + every { factory.bugleGservices } returns bugleGservices + every { factory.getPhoneUtils(ParticipantData.DEFAULT_SELF_SUB_ID) } returns phoneUtils + every { context.resources } returns resources + every { context.getString(R.string.send_sound_pref_key) } returns SEND_SOUND_PREF_KEY + every { context.getString(R.string.dump_sms_pref_key) } returns DUMP_SMS_PREF_KEY + every { context.getString(R.string.dump_mms_pref_key) } returns DUMP_MMS_PREF_KEY + every { resources.getBoolean(R.bool.send_sound_pref_default) } returns + SEND_SOUND_DEFAULT + every { resources.getBoolean(R.bool.dump_sms_pref_default) } returns DUMP_SMS_DEFAULT + every { resources.getBoolean(R.bool.dump_mms_pref_default) } returns DUMP_MMS_DEFAULT + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun getAppSettings_readsPhoneStateDebugStateAndBooleanPrefsWithResourceDefaults() { + runTest { + every { phoneUtils.isDefaultSmsApp } returns true + every { phoneUtils.defaultSmsAppLabel } returns DEFAULT_SMS_APP_LABEL + every { bugleGservices.getBoolean(any(), any()) } returns true + every { appPrefs.getBoolean(SEND_SOUND_PREF_KEY, SEND_SOUND_DEFAULT) } returns false + every { appPrefs.getBoolean(DUMP_SMS_PREF_KEY, DUMP_SMS_DEFAULT) } returns true + every { appPrefs.getBoolean(DUMP_MMS_PREF_KEY, DUMP_MMS_DEFAULT) } returns false + + val result = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ).getAppSettings() + + assertTrue(result.isDefaultSmsApp) + assertEquals(DEFAULT_SMS_APP_LABEL, result.defaultSmsAppLabel) + assertFalse(result.sendSoundEnabled) + assertTrue(result.isDebugEnabled) + assertTrue(result.dumpSmsEnabled) + assertFalse(result.dumpMmsEnabled) + verify(exactly = 1) { + appPrefs.getBoolean(SEND_SOUND_PREF_KEY, SEND_SOUND_DEFAULT) + appPrefs.getBoolean(DUMP_SMS_PREF_KEY, DUMP_SMS_DEFAULT) + appPrefs.getBoolean(DUMP_MMS_PREF_KEY, DUMP_MMS_DEFAULT) + } + } + } + + @Test + fun setBooleanPref_writesEveryApplicationBooleanPreferenceKey() { + runTest { + every { appPrefs.putBoolean(any(), any()) } just runs + val repository = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + + repository.setBooleanPref( + pref = AppBooleanPref.SEND_SOUND, + enabled = true, + ) + repository.setBooleanPref( + pref = AppBooleanPref.DUMP_SMS, + enabled = false, + ) + repository.setBooleanPref( + pref = AppBooleanPref.DUMP_MMS, + enabled = true, + ) + + verify(exactly = 1) { + appPrefs.putBoolean( + SEND_SOUND_PREF_KEY, + true, + ) + appPrefs.putBoolean( + DUMP_SMS_PREF_KEY, + false, + ) + appPrefs.putBoolean( + DUMP_MMS_PREF_KEY, + true, + ) + } + } + } + + private fun createRepository(ioDispatcher: CoroutineDispatcher): AppSettingsRepositoryImpl { + return AppSettingsRepositoryImpl( + context = context, + ioDispatcher = ioDispatcher, + ) + } + + private companion object { + private const val DEFAULT_SMS_APP_LABEL = "Messaging" + private const val DUMP_MMS_DEFAULT = false + private const val DUMP_MMS_PREF_KEY = "dump_mms" + private const val DUMP_SMS_DEFAULT = true + private const val DUMP_SMS_PREF_KEY = "dump_sms" + private const val SEND_SOUND_DEFAULT = true + private const val SEND_SOUND_PREF_KEY = "send_sound" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt new file mode 100644 index 000000000..926d8788e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt @@ -0,0 +1,54 @@ +package com.android.messaging.ui.appsettings.general.mapper.appsettingsuistatemapper + +import android.content.Context +import com.android.messaging.R +import com.android.messaging.data.appsettings.model.AppSettings +import com.android.messaging.ui.appsettings.general.mapper.AppSettingsUiStateMapperImpl +import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class AppSettingsUiStateMapperImplTest { + + @Test + fun map_formatsDefaultSmsAppLabelAndCopiesEveryField() { + val context = mockk() + every { + context.getString(R.string.default_sms_app, DEFAULT_SMS_APP_LABEL) + } returns FORMATTED_DEFAULT_SMS_APP_LABEL + val mapper = AppSettingsUiStateMapperImpl(context = context) + + val result = mapper.map( + appSettings = AppSettings( + isDefaultSmsApp = true, + defaultSmsAppLabel = DEFAULT_SMS_APP_LABEL, + sendSoundEnabled = false, + isDebugEnabled = true, + dumpSmsEnabled = true, + dumpMmsEnabled = false, + ), + ) + + assertEquals( + AppSettingsUiState( + isDefaultSmsApp = true, + defaultSmsAppLabel = FORMATTED_DEFAULT_SMS_APP_LABEL, + sendSoundEnabled = false, + isDebugEnabled = true, + dumpSmsEnabled = true, + dumpMmsEnabled = false, + ), + result, + ) + } + + private companion object { + private const val DEFAULT_SMS_APP_LABEL = "Messaging" + private const val FORMATTED_DEFAULT_SMS_APP_LABEL = "Default SMS app: Messaging" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/BaseSubscriptionSettingsUiStateMapperTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/BaseSubscriptionSettingsUiStateMapperTest.kt new file mode 100644 index 000000000..2c547d897 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/BaseSubscriptionSettingsUiStateMapperTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.appsettings.subscription.mapper.subscriptionsettingsuistatemapper + +import android.content.Context +import com.android.messaging.R +import com.android.messaging.data.subscriptionsettings.model.PerSubscriptionData +import com.android.messaging.data.subscriptionsettings.model.SubscriptionSettingsData +import com.android.messaging.ui.appsettings.subscription.mapper.SubscriptionSettingsUiStateMapperImpl +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.Before + +internal abstract class BaseSubscriptionSettingsUiStateMapperTest { + + protected val context: Context = mockk() + protected val mapper = SubscriptionSettingsUiStateMapperImpl(context = context) + + @Before + fun setUpBaseSubscriptionSettingsUiStateMapperTest() { + every { context.getString(R.string.advanced_settings) } returns ADVANCED_SETTINGS_LABEL + every { + context.getString(R.string.sim_specific_settings, VERIZON_SUBSCRIPTION_NAME) + } returns VERIZON_SIM_SPECIFIC_LABEL + every { + context.getString(R.string.sim_specific_settings, T_MOBILE_SUBSCRIPTION_NAME) + } returns T_MOBILE_SIM_SPECIFIC_LABEL + every { + context.getString(R.string.sim_specific_settings, null) + } returns NULL_SIM_SPECIFIC_LABEL + every { + context.getString(R.string.unknown_phone_number_pref_display_value) + } returns UNKNOWN_PHONE_NUMBER_LABEL + } + + protected fun subscriptionData( + isDefaultSmsApp: Boolean = false, + activeSubscriptionCount: Int = 1, + isCellBroadcastAppEnabled: Boolean = false, + defaultSelfSubscription: PerSubscriptionData = perSubscription(), + nonDefaultActiveSelfSubscriptions: List = persistentListOf(), + ): SubscriptionSettingsData { + return SubscriptionSettingsData( + isDefaultSmsApp = isDefaultSmsApp, + activeSubscriptionCount = activeSubscriptionCount, + isCellBroadcastAppEnabled = isCellBroadcastAppEnabled, + defaultSelfSubscription = defaultSelfSubscription, + nonDefaultActiveSelfSubscriptions = nonDefaultActiveSelfSubscriptions.toImmutableList(), + ) + } + + protected fun perSubscription( + subId: Int = 1, + subscriptionName: String? = "SIM 1", + savedPhoneNumber: String = "", + defaultPhoneNumber: String = "", + formattedSavedPhoneNumber: String? = null, + formattedDefaultPhoneNumber: String? = null, + isGroupMmsSupported: Boolean = false, + isGroupMmsEnabled: Boolean = true, + autoRetrieveMms: Boolean = true, + autoRetrieveMmsWhenRoaming: Boolean = false, + isDeliveryReportsSupported: Boolean = false, + deliveryReportsEnabled: Boolean = false, + showCellBroadcast: Boolean = false, + ): PerSubscriptionData { + return PerSubscriptionData( + subId = subId, + subscriptionName = subscriptionName, + savedPhoneNumber = savedPhoneNumber, + defaultPhoneNumber = defaultPhoneNumber, + formattedSavedPhoneNumber = formattedSavedPhoneNumber, + formattedDefaultPhoneNumber = formattedDefaultPhoneNumber, + isGroupMmsSupported = isGroupMmsSupported, + isGroupMmsEnabled = isGroupMmsEnabled, + autoRetrieveMms = autoRetrieveMms, + autoRetrieveMmsWhenRoaming = autoRetrieveMmsWhenRoaming, + isDeliveryReportsSupported = isDeliveryReportsSupported, + deliveryReportsEnabled = deliveryReportsEnabled, + showCellBroadcast = showCellBroadcast, + ) + } + + private companion object { + private const val ADVANCED_SETTINGS_LABEL = "Advanced settings" + private const val NULL_SIM_SPECIFIC_LABEL = "SIM-specific settings: null" + private const val T_MOBILE_SIM_SPECIFIC_LABEL = "SIM-specific settings: T-Mobile" + private const val T_MOBILE_SUBSCRIPTION_NAME = "T-Mobile" + private const val UNKNOWN_PHONE_NUMBER_LABEL = "Unknown phone number" + private const val VERIZON_SIM_SPECIFIC_LABEL = "SIM-specific settings: Verizon" + private const val VERIZON_SUBSCRIPTION_NAME = "Verizon" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSelectionTest.kt new file mode 100644 index 000000000..58c75e384 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSelectionTest.kt @@ -0,0 +1,135 @@ +package com.android.messaging.ui.appsettings.subscription.mapper.subscriptionsettingsuistatemapper + +import com.android.messaging.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class SubscriptionSettingsUiStateMapperSelectionTest : + BaseSubscriptionSettingsUiStateMapperTest() { + + @Test + fun map_noActiveSubscriptions_isNotMultiSimAndProducesNoSubscriptions() { + val uiState = mapper.map( + data = subscriptionData(activeSubscriptionCount = 0), + ) + + assertEquals(false, uiState.isMultiSim) + assertTrue(uiState.isLoaded) + assertTrue(uiState.subscriptions.isEmpty()) + } + + @Test + fun map_singleActiveSubscription_mapsDefaultSubscriptionWithAdvancedSettingsLabel() { + val uiState = mapper.map( + data = subscriptionData( + activeSubscriptionCount = 1, + defaultSelfSubscription = perSubscription(subId = 5), + ), + ) + + assertEquals(false, uiState.isMultiSim) + assertEquals(1, uiState.subscriptions.size) + assertEquals(5, uiState.subscriptions.first().subId) + assertEquals( + context.getString(R.string.advanced_settings), + uiState.subscriptions.first().displayName, + ) + } + + @Test + fun map_singleActiveSubscription_ignoresNonDefaultSubscriptions() { + val uiState = mapper.map( + data = subscriptionData( + activeSubscriptionCount = 1, + defaultSelfSubscription = perSubscription(subId = 3), + nonDefaultActiveSelfSubscriptions = listOf(perSubscription(subId = 99)), + ), + ) + + assertEquals(1, uiState.subscriptions.size) + assertEquals(3, uiState.subscriptions.first().subId) + } + + @Test + fun map_multipleNonDefaultSubscriptions_mapsEachInOrderWithSimSpecificLabel() { + val uiState = mapper.map( + data = subscriptionData( + activeSubscriptionCount = 2, + nonDefaultActiveSelfSubscriptions = listOf( + perSubscription(subId = 1, subscriptionName = "Verizon"), + perSubscription(subId = 2, subscriptionName = "T-Mobile"), + ), + ), + ) + + assertEquals(true, uiState.isMultiSim) + assertEquals(listOf(1, 2), uiState.subscriptions.map { it.subId }) + assertEquals( + context.getString(R.string.sim_specific_settings, "Verizon"), + uiState.subscriptions[0].displayName, + ) + assertEquals( + context.getString(R.string.sim_specific_settings, "T-Mobile"), + uiState.subscriptions[1].displayName, + ) + } + + @Test + fun map_multiSimWithSingleNonDefault_mapsThatSubscriptionWithAdvancedSettingsLabel() { + val uiState = mapper.map( + data = subscriptionData( + activeSubscriptionCount = 2, + nonDefaultActiveSelfSubscriptions = listOf(perSubscription(subId = 9)), + ), + ) + + assertEquals(true, uiState.isMultiSim) + assertEquals(1, uiState.subscriptions.size) + assertEquals(9, uiState.subscriptions.first().subId) + assertEquals( + context.getString(R.string.advanced_settings), + uiState.subscriptions.first().displayName, + ) + } + + @Test + fun map_multiSimWithNoNonDefaults_fallsBackToDefaultSubscriptionWithAdvancedSettingsLabel() { + val uiState = mapper.map( + data = subscriptionData( + activeSubscriptionCount = 2, + defaultSelfSubscription = perSubscription(subId = 3), + nonDefaultActiveSelfSubscriptions = emptyList(), + ), + ) + + assertEquals(true, uiState.isMultiSim) + assertEquals(1, uiState.subscriptions.size) + assertEquals(3, uiState.subscriptions.first().subId) + assertEquals( + context.getString(R.string.advanced_settings), + uiState.subscriptions.first().displayName, + ) + } + + @Test + fun map_multipleNonDefaultsWithNullName_passesNullThroughToSimSpecificLabel() { + val uiState = mapper.map( + data = subscriptionData( + activeSubscriptionCount = 2, + nonDefaultActiveSelfSubscriptions = listOf( + perSubscription(subId = 1, subscriptionName = null), + perSubscription(subId = 2, subscriptionName = "T-Mobile"), + ), + ), + ) + + assertEquals( + context.getString(R.string.sim_specific_settings, null), + uiState.subscriptions[0].displayName, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSubscriptionMappingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSubscriptionMappingTest.kt new file mode 100644 index 000000000..4f583d980 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/mapper/subscriptionsettingsuistatemapper/SubscriptionSettingsUiStateMapperSubscriptionMappingTest.kt @@ -0,0 +1,158 @@ +package com.android.messaging.ui.appsettings.subscription.mapper.subscriptionsettingsuistatemapper + +import com.android.messaging.R +import com.android.messaging.ui.appsettings.subscription.model.SubscriptionUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class SubscriptionSettingsUiStateMapperSubscriptionMappingTest : + BaseSubscriptionSettingsUiStateMapperTest() { + + @Test + fun map_mapsEverySubscriptionFieldOntoUiState() { + val uiState = mapper.map( + data = subscriptionData( + isDefaultSmsApp = true, + activeSubscriptionCount = 1, + isCellBroadcastAppEnabled = true, + defaultSelfSubscription = perSubscription( + subId = 7, + savedPhoneNumber = "+15551230000", + defaultPhoneNumber = "+15559990000", + formattedSavedPhoneNumber = "(555) 123-0000", + isGroupMmsSupported = true, + isGroupMmsEnabled = false, + autoRetrieveMms = false, + autoRetrieveMmsWhenRoaming = true, + isDeliveryReportsSupported = true, + deliveryReportsEnabled = false, + showCellBroadcast = true, + ), + ), + ) + + assertEquals( + SubscriptionUiState( + subId = 7, + displayName = context.getString(R.string.advanced_settings), + displayDetail = "(555) 123-0000", + phoneNumber = "+15551230000", + defaultPhoneNumber = "+15559990000", + isGroupMmsSupported = true, + isGroupMmsEnabled = false, + autoRetrieveMms = false, + autoRetrieveMmsWhenRoaming = true, + isDeliveryReportsSupported = true, + deliveryReportsEnabled = false, + isWirelessAlertsSupported = true, + isDefaultSmsApp = true, + ), + uiState.subscriptions.first(), + ) + } + + @Test + fun map_multiSimSubscriptions_mapEachNonDefaultFromItsOwnFields() { + val uiState = mapper.map( + data = subscriptionData( + isDefaultSmsApp = false, + activeSubscriptionCount = 2, + isCellBroadcastAppEnabled = true, + nonDefaultActiveSelfSubscriptions = listOf( + perSubscription( + subId = 1, + subscriptionName = "Verizon", + formattedSavedPhoneNumber = "(555) 111-0000", + showCellBroadcast = true, + ), + perSubscription( + subId = 2, + subscriptionName = "T-Mobile", + formattedSavedPhoneNumber = "(555) 222-0000", + showCellBroadcast = false, + ), + ), + ), + ) + + assertEquals("(555) 111-0000", uiState.subscriptions[0].displayDetail) + assertTrue(uiState.subscriptions[0].isWirelessAlertsSupported) + assertFalse(uiState.subscriptions[0].isDefaultSmsApp) + assertEquals("(555) 222-0000", uiState.subscriptions[1].displayDetail) + assertFalse(uiState.subscriptions[1].isWirelessAlertsSupported) + } + + @Test + fun map_prefersSavedFormattedNumberOverDefaultFormattedNumber() { + val uiState = mapper.map( + data = subscriptionData( + defaultSelfSubscription = perSubscription( + formattedSavedPhoneNumber = "(555) 111-1111", + formattedDefaultPhoneNumber = "(555) 222-2222", + ), + ), + ) + + assertEquals("(555) 111-1111", uiState.subscriptions.first().displayDetail) + } + + @Test + fun map_whenSavedFormattedNumberMissing_usesDefaultFormattedNumber() { + val uiState = mapper.map( + data = subscriptionData( + defaultSelfSubscription = perSubscription( + formattedSavedPhoneNumber = null, + formattedDefaultPhoneNumber = "(555) 222-2222", + ), + ), + ) + + assertEquals("(555) 222-2222", uiState.subscriptions.first().displayDetail) + } + + @Test + fun map_whenNoFormattedNumbers_usesUnknownPhoneNumberLabel() { + val uiState = mapper.map( + data = subscriptionData( + defaultSelfSubscription = perSubscription( + formattedSavedPhoneNumber = null, + formattedDefaultPhoneNumber = null, + ), + ), + ) + + assertEquals( + context.getString(R.string.unknown_phone_number_pref_display_value), + uiState.subscriptions.first().displayDetail, + ) + } + + @Test + fun map_wirelessAlertsUnsupported_whenCellBroadcastShownButAppDisabled() { + val uiState = mapper.map( + data = subscriptionData( + isCellBroadcastAppEnabled = false, + defaultSelfSubscription = perSubscription(showCellBroadcast = true), + ), + ) + + assertFalse(uiState.subscriptions.first().isWirelessAlertsSupported) + } + + @Test + fun map_wirelessAlertsUnsupported_whenCellBroadcastNotShownButAppEnabled() { + val uiState = mapper.map( + data = subscriptionData( + isCellBroadcastAppEnabled = true, + defaultSelfSubscription = perSubscription(showCellBroadcast = false), + ), + ) + + assertFalse(uiState.subscriptions.first().isWirelessAlertsSupported) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/util/ImageUtilsTest.kt b/app/src/test/kotlin/com/android/messaging/util/ImageUtilsTest.kt new file mode 100644 index 000000000..c36d38e54 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/util/ImageUtilsTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.util + +import android.graphics.Bitmap +import android.media.ExifInterface +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ImageUtilsTest { + + @Test + fun getOrientation_readsOrientationValueFromJpegExif() { + val jpeg = createJpegWithExifOrientation(orientation = ExifInterface.ORIENTATION_ROTATE_90) + + assertEquals( + ExifInterface.ORIENTATION_ROTATE_90, + ImageUtils.getOrientation(ByteArrayInputStream(jpeg)), + ) + } + + @Test + fun getOrientation_parsesExifFromStreamThatDoesNotSupportMark() { + val jpeg = createJpegWithExifOrientation(orientation = ExifInterface.ORIENTATION_ROTATE_90) + val streamWithoutMarkSupport = MarkUnsupportedInputStream( + delegate = ByteArrayInputStream(jpeg), + ) + + assertEquals( + ExifInterface.ORIENTATION_ROTATE_90, + ImageUtils.getOrientation(streamWithoutMarkSupport), + ) + } + + @Test + fun getOrientation_returnsUndefinedForNonJpegStream() { + val nonJpeg = ByteArrayInputStream(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)) + + assertEquals( + ExifInterface.ORIENTATION_UNDEFINED, + ImageUtils.getOrientation(nonJpeg), + ) + } + + @Test + fun getOrientation_returnsUndefinedForNullStream() { + assertEquals( + ExifInterface.ORIENTATION_UNDEFINED, + ImageUtils.getOrientation(null), + ) + } + + private fun createJpegWithExifOrientation(orientation: Int): ByteArray { + val fixtureFile = File.createTempFile( + "exif-orientation-fixture", + ".jpg", + ) + try { + FileOutputStream(fixtureFile).use { outputStream -> + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888) + val isCompressed = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + check(isCompressed) { + "Failed to compress JPEG fixture" + } + } + ExifInterface(fixtureFile.absolutePath).apply { + setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString()) + saveAttributes() + } + return fixtureFile.readBytes() + } finally { + fixtureFile.delete() + } + } + + private class MarkUnsupportedInputStream( + private val delegate: InputStream, + ) : InputStream() { + + override fun read(): Int { + return delegate.read() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return delegate.read(buffer, offset, length) + } + } +} From dbec2978b042862ae6e639bb5f71af6eacbf22ef Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:13:54 +0300 Subject: [PATCH 18/38] Cover conversation composer and top-level UI --- .../AddParticipantsScreenTest.kt | 238 +++++ ...ConversationMediaThumbnailRenderingTest.kt | 155 ++++ ...versationVCardAttachmentCardContentTest.kt | 135 +++ .../ui/ConversationAttachmentPreviewTest.kt | 213 +++++ .../composer/ui/ConversationComposeBarTest.kt | 828 ++++++++++++++++++ .../ui/ConversationSimSelectorSheetTest.kt | 130 +++ .../conversation/entry/NewChatScreenTest.kt | 237 +++++ .../ConversationTopAppBarSimSelectorTest.kt | 201 +++++ .../metadata/ui/ConversationTopAppBarTest.kt | 371 ++++++++ 9 files changed, 2508 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreenTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContentTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreviewTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheetTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarSimSelectorTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreenTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreenTest.kt new file mode 100644 index 000000000..573c1d665 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreenTest.kt @@ -0,0 +1,238 @@ +package com.android.messaging.ui.conversation.addparticipants + +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import com.android.messaging.ui.core.AppTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AddParticipantsScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun screen_withConversationId_notifiesModel() { + val model = createScreenModel() + + setContent(model = model) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + model.onConversationIdChanged(conversationId = CONVERSATION_ID) + } + } + } + + @Test + fun confirmButton_withSelectedRecipient_forwardsClick() { + val model = createScreenModel( + initialUiState = AddParticipantsUiState( + isLoadingConversationParticipants = false, + selectedRecipients = persistentListOf( + selectedRecipient(destination = "+15550100"), + ), + ), + ) + + setContent(model = model) + + composeTestRule + .onNodeWithTag(ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + model.onConfirmClick() + } + } + } + + @Test + fun title_defaultState_isRendered() { + setContent(model = createScreenModel()) + + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.conversation_add_people)) + .assertIsDisplayed() + } + + @Test + fun navigationIcon_forwardsBackClick() { + val model = createScreenModel() + val onNavigateBack = mockk<() -> Unit>(relaxed = true) + + setContent( + model = model, + onNavigateBack = onNavigateBack, + ) + + composeTestRule + .onNodeWithContentDescription(targetContext.getString(R.string.back)) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onNavigateBack.invoke() + } + } + } + + @Test + fun navigateEffect_forwardsConversationId() { + val effectsFlow = MutableSharedFlow(extraBufferCapacity = 1) + val model = createScreenModel(effectsFlow = effectsFlow) + val onNavigateToConversation = mockk<(String) -> Unit>(relaxed = true) + + setContent( + model = model, + onNavigateToConversation = onNavigateToConversation, + ) + waitForEffectsCollector(effectsFlow = effectsFlow) + + composeTestRule.runOnIdle { + effectsFlow.tryEmit( + AddParticipantsEffect.NavigateToConversation( + conversationId = TARGET_CONVERSATION_ID, + ), + ) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onNavigateToConversation.invoke(TARGET_CONVERSATION_ID) + } + } + } + + @Test + fun selectedRecipientWhileResolving_disablesConfirmAndShowsProgress() { + val model = createScreenModel( + initialUiState = AddParticipantsUiState( + isLoadingConversationParticipants = false, + isResolvingConversation = true, + selectedRecipients = persistentListOf( + selectedRecipient(destination = "+15550100"), + ), + ), + ) + + setContent(model = model) + + composeTestRule + .onNodeWithTag(testTag = ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG) + .assertIsDisplayed() + .assertIsNotEnabled() + composeTestRule + .onAllNodes( + matcher = hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate), + ) + .assertCountEquals(expectedSize = 1) + } + + @Test + fun selectedRecipient_usesAddMoreQueryHint() { + val model = createScreenModel( + initialUiState = AddParticipantsUiState( + isLoadingConversationParticipants = false, + selectedRecipients = persistentListOf( + selectedRecipient(destination = "+15550100"), + ), + ), + ) + + setContent(model = model) + + composeTestRule + .onNodeWithText( + text = targetContext.getString(R.string.recipient_selection_query_hint_more), + ) + .assertIsDisplayed() + } + + private fun setContent( + model: AddParticipantsScreenModel, + onNavigateBack: () -> Unit = {}, + onNavigateToConversation: (String) -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + AddParticipantsScreen( + conversationId = CONVERSATION_ID, + onNavigateBack = onNavigateBack, + onNavigateToConversation = onNavigateToConversation, + screenModel = model, + ) + } + } + } + + private fun selectedRecipient(destination: String): SelectedRecipient { + return SelectedRecipient( + destination = destination, + label = destination, + displayDestination = destination, + photoUri = null, + ) + } + + private fun createScreenModel( + effectsFlow: MutableSharedFlow = MutableSharedFlow(), + ): AddParticipantsScreenModel { + return createScreenModel( + initialUiState = defaultUiState, + effectsFlow = effectsFlow, + ) + } + + private fun createScreenModel( + initialUiState: AddParticipantsUiState, + effectsFlow: MutableSharedFlow = MutableSharedFlow(), + ): AddParticipantsScreenModel { + return mockk(relaxed = true) { + every { effects } returns effectsFlow + every { uiState } returns MutableStateFlow(value = initialUiState) + } + } + + private fun waitForEffectsCollector(effectsFlow: MutableSharedFlow) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + effectsFlow.subscriptionCount.value == 1 + } + } + + private companion object { + private const val TARGET_CONVERSATION_ID = "conversation-target" + + private val defaultUiState = AddParticipantsUiState( + isLoadingConversationParticipants = false, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailRenderingTest.kt new file mode 100644 index 000000000..04dee98c8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailRenderingTest.kt @@ -0,0 +1,155 @@ +package com.android.messaging.ui.conversation.attachment.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color as AndroidColor +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.common.test.helpers.targetContext +import com.android.messaging.ui.core.AppTheme +import java.io.ByteArrayInputStream +import kotlin.math.abs +import kotlin.math.roundToInt +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.GraphicsMode + +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaThumbnailRenderingTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun invalidImageUriWithBitmapLoader_keepsPlaceholderVisible() { + setThumbnailContent( + contentType = IMAGE_CONTENT_TYPE, + size = IntSize(width = THUMBNAIL_SIZE_PX, height = THUMBNAIL_SIZE_PX), + backgroundColor = Color.Red, + useBitmapLoader = true, + ) + + assertThumbnailCenterColor(expectedColor = Color.Red) + } + + @Test + fun nonImageContent_usesBitmapPathPlaceholder() { + setThumbnailContent( + contentType = FILE_CONTENT_TYPE, + size = IntSize(width = THUMBNAIL_SIZE_PX, height = THUMBNAIL_SIZE_PX), + backgroundColor = Color.Blue, + useBitmapLoader = false, + ) + + assertThumbnailCenterColor(expectedColor = Color.Blue) + } + + @Test + fun zeroRequestedSize_sanitizesAndRendersPlaceholder() { + setThumbnailContent( + contentType = IMAGE_CONTENT_TYPE, + size = IntSize.Zero, + backgroundColor = Color.Green, + useBitmapLoader = true, + ) + + assertThumbnailCenterColor(expectedColor = Color.Green) + } + + private fun setThumbnailContent( + contentType: String, + size: IntSize, + backgroundColor: Color, + useBitmapLoader: Boolean, + ) { + Shadows + .shadowOf(targetContext.contentResolver) + .registerInputStreamSupplier(Uri.parse(INVALID_CONTENT_URI)) { + ByteArrayInputStream(byteArrayOf()) + } + + composeTestRule.setContent { + AppTheme { + ConversationMediaThumbnail( + modifier = Modifier + .size(size = THUMBNAIL_SIZE_DP.dp) + .testTag(tag = THUMBNAIL_TAG), + contentUri = INVALID_CONTENT_URI, + contentType = contentType, + size = size, + contentScale = ContentScale.Crop, + backgroundColor = backgroundColor, + useBitmapLoader = useBitmapLoader, + ) + } + } + } + + private fun assertThumbnailCenterColor(expectedColor: Color) { + composeTestRule.waitForIdle() + + val thumbnailNode = composeTestRule + .onNodeWithTag(testTag = THUMBNAIL_TAG) + .assertIsDisplayed() + .assertWidthIsEqualTo(expectedWidth = THUMBNAIL_SIZE_DP.dp) + .assertHeightIsEqualTo(expectedHeight = THUMBNAIL_SIZE_DP.dp) + val bounds = thumbnailNode + .fetchSemanticsNode() + .boundsInRoot + + val bitmap = composeTestRule.runOnIdle { + captureActivityBitmap() + } + val centerPixel = bitmap.getPixel( + (bounds.left + bounds.width / 2f).roundToInt(), + (bounds.top + bounds.height / 2f).roundToInt(), + ) + val redMatches = + abs(AndroidColor.red(centerPixel) / COLOR_COMPONENT_MAX - expectedColor.red) <= + COLOR_DELTA + val greenMatches = + abs(AndroidColor.green(centerPixel) / COLOR_COMPONENT_MAX - expectedColor.green) <= + COLOR_DELTA + val blueMatches = + abs(AndroidColor.blue(centerPixel) / COLOR_COMPONENT_MAX - expectedColor.blue) <= + COLOR_DELTA + + assertTrue(redMatches && greenMatches && blueMatches) + } + + private fun captureActivityBitmap(): Bitmap { + val view = composeTestRule.activity.window.decorView.rootView + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + + return bitmap + } + + private companion object { + private const val COLOR_COMPONENT_MAX = 255f + private const val COLOR_DELTA = 0.08f + private const val FILE_CONTENT_TYPE = "application/pdf" + private const val IMAGE_CONTENT_TYPE = "image/jpeg" + private const val INVALID_CONTENT_URI = "content://com.android.messaging.invalid/missing" + private const val THUMBNAIL_SIZE_DP = 48 + private const val THUMBNAIL_SIZE_PX = 96 + private const val THUMBNAIL_TAG = "conversation-media-thumbnail-under-test" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContentTest.kt new file mode 100644 index 000000000..0179c5883 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContentTest.kt @@ -0,0 +1,135 @@ +package com.android.messaging.ui.conversation.attachment.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.core.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationVCardAttachmentCardContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun contactTitle_rendersFallbackInitialAndSubtitle() { + setContent( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + titleText = CONTACT_TITLE, + titleTextResId = null, + subtitleText = CONTACT_SUBTITLE, + subtitleTextResId = null, + ) + + composeTestRule + .onNodeWithText(text = CONTACT_TITLE) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = CONTACT_SUBTITLE) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = CONTACT_INITIAL) + .assertIsDisplayed() + } + + @Test + fun blankContactTitle_usesResourceTitleAndOmitsInitialLabel() { + val fallbackTitle = targetContext.getString(R.string.notification_vcard) + + setContent( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + titleText = null, + titleTextResId = R.string.notification_vcard, + subtitleText = null, + subtitleTextResId = null, + ) + + composeTestRule + .onNodeWithText(text = fallbackTitle) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = CONTACT_INITIAL) + .assertDoesNotExist() + } + + @Test + fun remoteAvatarUri_keepsTextFallbackInitial() { + setContent( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = REMOTE_AVATAR_URI, + titleText = REMOTE_CONTACT_TITLE, + titleTextResId = null, + subtitleText = null, + subtitleTextResId = R.string.vcard_tap_hint, + ) + + composeTestRule + .onNodeWithText(text = REMOTE_CONTACT_TITLE) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = REMOTE_CONTACT_INITIAL) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.vcard_tap_hint)) + .assertIsDisplayed() + } + + @Test + fun locationContent_usesLocationTitleAndSubtitleResources() { + setContent( + type = ConversationVCardAttachmentType.LOCATION, + avatarUri = null, + titleText = null, + titleTextResId = R.string.notification_location, + subtitleText = null, + subtitleTextResId = R.string.vcard_tap_hint, + ) + + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.notification_location)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.vcard_tap_hint)) + .assertIsDisplayed() + } + + private fun setContent( + type: ConversationVCardAttachmentType, + avatarUri: String?, + titleText: String?, + titleTextResId: Int?, + subtitleText: String?, + subtitleTextResId: Int?, + ) { + composeTestRule.setContent { + AppTheme { + ConversationVCardAttachmentCardContent( + type = type, + avatarUri = avatarUri, + titleText = titleText, + titleTextResId = titleTextResId, + subtitleText = subtitleText, + subtitleTextResId = subtitleTextResId, + ) + } + } + } + + private companion object { + private const val CONTACT_INITIAL = "S" + private const val CONTACT_SUBTITLE = "sam@example.com" + private const val CONTACT_TITLE = "Sam Rivera" + private const val REMOTE_AVATAR_URI = "https://example.com/avatar.jpg" + private const val REMOTE_CONTACT_INITIAL = "R" + private const val REMOTE_CONTACT_TITLE = "Remote Person" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreviewTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreviewTest.kt new file mode 100644 index 000000000..157cad4b2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreviewTest.kt @@ -0,0 +1,213 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel.Resolved.VCard +import com.android.messaging.ui.conversation.conversationAttachmentPreviewItemTestTag +import com.android.messaging.ui.conversation.conversationAttachmentPreviewRemoveButtonTestTag +import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationAttachmentPreviewTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun emptyAttachments_doNotRenderList() { + setContent(attachments = persistentListOf()) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun mixedAttachments_rendersAllItems() { + setContent() + + composeTestRule + .onNodeWithTag(conversationAttachmentPreviewItemTestTag(PENDING_KEY)) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(conversationAttachmentPreviewItemTestTag(RESOLVED_KEY)) + .assertIsDisplayed() + } + + @Test + fun resolvedVisualAttachment_clickForwardsCallback() { + val clicks = mutableListOf() + setContent( + onResolvedAttachmentClick = { clicks += it }, + ) + + composeTestRule + .onNodeWithTag(conversationAttachmentPreviewItemTestTag(RESOLVED_KEY)) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(listOf(resolvedAttachment), clicks) + } + } + + @Test + fun vCardAttachment_rendersPreparedUiModelText() { + setContent( + attachments = persistentListOf(vCardAttachment), + ) + + composeTestRule + .onNodeWithText("Sam Rivera") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("555-000-8901") + .assertIsDisplayed() + } + + @Test + fun audioAttachment_rendersFormattedDuration() { + setContent( + attachments = persistentListOf(audioAttachment), + ) + + composeTestRule + .onNodeWithText("05:39") + .assertIsDisplayed() + } + + @Test + fun pendingAttachment_removeButtonForwardsCallback() { + val removals = mutableListOf() + setContent( + onPendingAttachmentRemove = { removals += it }, + ) + + composeTestRule + .onNodeWithTag(conversationAttachmentPreviewRemoveButtonTestTag(PENDING_KEY)) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(listOf(PENDING_KEY), removals) + } + } + + @Test + fun pendingAudioFinalizingAttachment_rendersAudioPlaceholderWithoutRemoveButton() { + setContent( + attachments = persistentListOf(pendingAudioFinalizingAttachment), + ) + + composeTestRule + .onNodeWithText( + targetContext.getString(R.string.audio_recording_finalizing_attachment_label), + ) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("00:00") + .assertCountEquals(expectedSize = 0) + composeTestRule + .onAllNodesWithTag( + conversationAttachmentPreviewRemoveButtonTestTag(PENDING_AUDIO_KEY), + ) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun resolvedAttachment_removeButtonForwardsCallback() { + val removals = mutableListOf() + setContent( + onResolvedAttachmentRemove = { removals += it }, + ) + + composeTestRule + .onNodeWithTag(conversationAttachmentPreviewRemoveButtonTestTag(RESOLVED_KEY)) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(listOf(RESOLVED_CONTENT_URI), removals) + } + } + + private fun setContent( + attachments: ImmutableList = + persistentListOf(pendingAttachment, resolvedAttachment), + onPendingAttachmentRemove: (String) -> Unit = {}, + onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit = {}, + onResolvedAttachmentRemove: (String) -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + ConversationAttachmentPreview( + attachments = attachments, + onPendingAttachmentRemove = onPendingAttachmentRemove, + onResolvedAttachmentClick = onResolvedAttachmentClick, + onResolvedAttachmentRemove = onResolvedAttachmentRemove, + ) + } + } + } + + private companion object { + private const val PENDING_KEY = "pending-1" + private const val PENDING_AUDIO_KEY = "pending-audio-1" + private const val RESOLVED_KEY = "resolved-1" + private const val RESOLVED_CONTENT_URI = "content://media/resolved/1" + + private val pendingAttachment = ComposerAttachmentUiModel.Pending.Generic( + key = PENDING_KEY, + contentType = "image/jpeg", + contentUri = "content://media/pending/1", + displayName = "pending.jpg", + ) + private val pendingAudioFinalizingAttachment = + ComposerAttachmentUiModel.Pending.AudioFinalizing( + key = PENDING_AUDIO_KEY, + contentType = "audio/3gpp", + contentUri = "pending://audio/1", + displayName = "", + ) + private val resolvedAttachment = ComposerAttachmentUiModel.Resolved.VisualMedia.Video( + key = RESOLVED_KEY, + contentType = "video/mp4", + contentUri = RESOLVED_CONTENT_URI, + captionText = "Caption", + width = 640, + height = 480, + ) + private val vCardAttachment = VCard( + key = "resolved-vcard-1", + contentType = "text/x-vCard", + contentUri = "content://contacts/as_vcard/1", + vCardUiModel = ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = "Sam Rivera", + subtitleText = "555-000-8901", + ), + ) + private val audioAttachment = ComposerAttachmentUiModel.Resolved.Audio( + key = "resolved-audio-1", + contentType = "audio/3gpp", + contentUri = "content://media/audio/1", + durationMillis = 339_000L, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt new file mode 100644 index 000000000..f0f4bee49 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt @@ -0,0 +1,828 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.click +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.testutil.performDisabledTouchClick +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_MMS_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ConversationSegmentCounterUiState +import com.android.messaging.ui.core.AppTheme +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationComposeBarTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun multiLineInput_growsTextFieldWithoutGrowingSendButton() { + setContent( + messageText = "Line 1\nLine 2\nLine 3\nLine 4", + subjectText = "", + ) + + val textFieldBounds = composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .getUnclippedBoundsInRoot() + val sendButtonBounds = composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .getUnclippedBoundsInRoot() + + assertTrue(textFieldBounds.height > sendButtonBounds.height) + } + + @Test + fun mmsSendProtocol_showsMmsIndicatorAndStateDescription() { + setContent( + messageText = "Hello", + subjectText = "", + sendProtocol = ConversationDraftSendProtocol.MMS, + segmentCounter = null, + ) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_MMS_INDICATOR_TEST_TAG, + useUnmergedTree = true, + ) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + targetContext.getString(R.string.mms_text), + ), + ) + } + + @Test + fun smsSendProtocol_hidesMmsIndicator() { + setContent( + messageText = "Hello", + subjectText = "", + sendProtocol = ConversationDraftSendProtocol.SMS, + segmentCounter = null, + ) + + composeTestRule + .onAllNodesWithTag( + testTag = CONVERSATION_MMS_INDICATOR_TEST_TAG, + useUnmergedTree = true, + ) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun enabledState_andCallbacks_areWiredCorrectly() { + var messageText = "" + var currentMessageText by mutableStateOf(value = "") + var sendClicks = 0 + + setContent( + messageText = { currentMessageText }, + isSendActionEnabled = true, + onMessageTextChange = { updatedText -> + currentMessageText = updatedText + messageText = updatedText + }, + onSendClick = { + sendClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .performTextInput("Hello") + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .assertIsEnabled() + .performClick() + + composeTestRule.runOnIdle { + assertEquals("Hello", messageText) + assertEquals(1, sendClicks) + } + } + + @Test + fun sendButton_canBeDisabled() { + var sendClicks = 0 + + setContent( + messageText = "Hello", + isSendActionEnabled = false, + onSendClick = { + sendClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performDisabledTouchClick() + + composeTestRule.runOnIdle { + assertEquals(0, sendClicks) + } + } + + @Test + fun textField_canBeDisabled() { + setContent( + messageText = "", + isMessageFieldEnabled = false, + isSendActionEnabled = false, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .assertIsNotEnabled() + } + + @Test + fun attachmentButton_performsHapticFeedbackAndOpensMenu() { + val hapticFeedback = createHapticFeedbackMock() + + setContent( + messageText = "", + isSendActionEnabled = false, + isAttachmentActionEnabled = true, + hapticFeedback = hapticFeedback, + ) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG) + .assertCountEquals(expectedSize = 0) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + + composeTestRule + .onAllNodesWithTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG) + .assertCountEquals(expectedSize = 1) + composeTestRule + .onAllNodesWithTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG) + .assertCountEquals(expectedSize = 1) + composeTestRule + .onAllNodesWithTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG) + .assertCountEquals(expectedSize = 1) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + } + } + } + + @Test + fun attachmentMenuMediaItem_forwardsCallback() { + var mediaClicks = 0 + + setContent( + messageText = "", + isSendActionEnabled = false, + isAttachmentActionEnabled = true, + onMediaPickerClick = { + mediaClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, mediaClicks) + } + } + + @Test + fun attachmentMenuAudioItem_forwardsLockedRecordingStartRequest() { + var audioClicks = 0 + + setContent( + messageText = "Message with text", + isSendActionEnabled = true, + isAttachmentActionEnabled = true, + onLockedAudioRecordingStartRequest = { + audioClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, audioClicks) + } + } + + @Test + fun attachmentMenuContactItem_forwardsCallback() { + var contactClicks = 0 + + setContent( + messageText = "", + isSendActionEnabled = false, + isAttachmentActionEnabled = true, + onContactAttachClick = { + contactClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, contactClicks) + } + } + + @Test + fun attachmentButton_canBeDisabled() { + setContent( + messageText = "", + subjectText = "", + isSendActionEnabled = false, + isAttachmentActionEnabled = false, + ) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + composeTestRule + .onAllNodesWithTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun emptyMessage_withRecordActionVisible_showsRecordButton() { + setContent( + messageText = "", + subjectText = "", + shouldShowRecordAction = true, + ) + + composeTestRule + .onNodeWithContentDescription( + targetContext.getString(R.string.audio_record_view_content_description), + ) + .assertIsDisplayed() + } + + @Test + fun recordingState_showsRecordingBarWithLockAffordanceAboveSendButton() { + setContent( + audioRecording = ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + durationMillis = 1_000L, + ), + messageText = "", + subjectText = "", + shouldShowRecordAction = true, + ) + + val sendButtonBounds = composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .getUnclippedBoundsInRoot() + val lockAffordanceBounds = composeTestRule + .onNodeWithTag(CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG) + .getUnclippedBoundsInRoot() + + composeTestRule + .onNodeWithTag(CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG) + .assertIsDisplayed() + + assertTrue(lockAffordanceBounds.bottom.value <= sendButtonBounds.top.value + 8f) + assertTrue( + kotlin.math.abs( + ( + (lockAffordanceBounds.left.value + lockAffordanceBounds.right.value) / 2f + ) - ( + (sendButtonBounds.left.value + sendButtonBounds.right.value) / 2f + ), + ) <= 8f, + ) + } + + @Test + fun lockedRecordingState_showsStopButtonFromUiState() { + setContent( + audioRecording = recordingAudioState(isLocked = true), + messageText = "", + subjectText = "", + shouldShowRecordAction = false, + ) + + composeTestRule + .onNodeWithContentDescription( + targetContext.getString(R.string.audio_record_stop_content_description), + ) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithTag(CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun activeRecording_controlsStayEnabledWhenRecordStartActionIsDisabled() { + var finishRequests = 0 + setContent( + audioRecording = recordingAudioState(isLocked = true), + messageText = "", + subjectText = "", + isRecordActionEnabled = false, + shouldShowRecordAction = false, + onAudioRecordingFinish = { + finishRequests += 1 + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .assertIsEnabled() + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, finishRequests) + } + } + + @Test + fun longPressRecordButton_startsAndFinishesRecording() { + var audioRecording by mutableStateOf(ConversationAudioRecordingUiState()) + var startRequests = 0 + var finishRequests = 0 + var cancelRequests = 0 + + setContent( + audioRecording = { audioRecording }, + messageText = { "" }, + isSendActionEnabled = false, + shouldShowRecordAction = { true }, + onAudioRecordingStartRequest = { + startRequests += 1 + audioRecording = recordingAudioState() + }, + onAudioRecordingFinish = { + finishRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + onAudioRecordingCancel = { + cancelRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + advanceEventTime(durationMillis = 700L) + up() + } + + composeTestRule.runOnIdle { + assertEquals(1, startRequests) + assertEquals(1, finishRequests) + assertEquals(0, cancelRequests) + } + } + + @Test + fun longPressAndDragLeft_cancelsRecording() { + var audioRecording by mutableStateOf(ConversationAudioRecordingUiState()) + var startRequests = 0 + var finishRequests = 0 + var cancelRequests = 0 + val cancelDragDistancePx = with(composeTestRule.density) { + (AUDIO_RECORD_CANCEL_THRESHOLD + 24.dp).toPx() + } + + setContent( + audioRecording = { audioRecording }, + messageText = { "" }, + isSendActionEnabled = false, + shouldShowRecordAction = { true }, + onAudioRecordingStartRequest = { + startRequests += 1 + audioRecording = recordingAudioState() + }, + onAudioRecordingFinish = { + finishRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + onAudioRecordingCancel = { + cancelRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + advanceEventTime(durationMillis = 700L) + moveBy( + Offset( + x = -cancelDragDistancePx, + y = 0f, + ), + ) + up() + } + + composeTestRule.runOnIdle { + assertEquals(1, startRequests) + assertEquals(0, finishRequests) + assertEquals(1, cancelRequests) + } + } + + @Test + fun lockGesture_emitsConfirmHapticAndKeepsRecordingActiveUntilStopTap() { + var audioRecording by mutableStateOf(ConversationAudioRecordingUiState()) + var startRequests = 0 + var lockRequests = 0 + var finishRequests = 0 + var cancelRequests = 0 + var shouldShowRecordAction by mutableStateOf(true) + val hapticFeedback = createHapticFeedbackMock() + val lockDragDistancePx = with(composeTestRule.density) { + (AUDIO_RECORD_LOCK_THRESHOLD + 24.dp).toPx() + } + + setContent( + audioRecording = { audioRecording }, + messageText = { "" }, + isSendActionEnabled = false, + shouldShowRecordAction = { shouldShowRecordAction }, + hapticFeedback = hapticFeedback, + onAudioRecordingStartRequest = { + startRequests += 1 + shouldShowRecordAction = false + audioRecording = recordingAudioState() + }, + onAudioRecordingFinish = { + finishRequests += 1 + audioRecording = finalizingAudioState() + }, + onAudioRecordingLock = { + lockRequests += 1 + audioRecording = recordingAudioState(isLocked = true) + true + }, + onAudioRecordingCancel = { + cancelRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + shouldShowRecordAction = true + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + advanceEventTime(durationMillis = 700L) + moveBy( + Offset( + x = 0f, + y = -lockDragDistancePx, + ), + ) + up() + } + + composeTestRule + .onNodeWithContentDescription( + targetContext.getString(R.string.audio_record_stop_content_description), + ) + .assertIsDisplayed() + + composeTestRule.runOnIdle { + assertEquals(1, startRequests) + assertEquals(1, lockRequests) + assertEquals(0, finishRequests) + assertEquals(0, cancelRequests) + assertEquals(ConversationAudioRecordingPhase.Recording, audioRecording.phase) + assertTrue(audioRecording.isLocked) + verify(exactly = 1) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) + } + } + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performClick() + + composeTestRule + .onAllNodesWithTag(CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onAllNodesWithTag(CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG) + .assertCountEquals(expectedSize = 0) + + composeTestRule.runOnIdle { + assertEquals(1, finishRequests) + assertEquals(0, cancelRequests) + assertEquals(ConversationAudioRecordingPhase.Finalizing, audioRecording.phase) + } + } + + @Test + fun lockedRecording_canStillSlideLeftToCancel() { + var audioRecording by mutableStateOf(ConversationAudioRecordingUiState()) + var finishRequests = 0 + var cancelRequests = 0 + val lockDragDistancePx = with(composeTestRule.density) { + (AUDIO_RECORD_LOCK_THRESHOLD + 24.dp).toPx() + } + val cancelDragDistancePx = with(composeTestRule.density) { + (AUDIO_RECORD_CANCEL_THRESHOLD + 24.dp).toPx() + } + + setContent( + audioRecording = { audioRecording }, + messageText = { "" }, + isSendActionEnabled = false, + shouldShowRecordAction = { true }, + onAudioRecordingStartRequest = { + audioRecording = recordingAudioState() + }, + onAudioRecordingFinish = { + finishRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + onAudioRecordingLock = { + audioRecording = recordingAudioState(isLocked = true) + true + }, + onAudioRecordingCancel = { + cancelRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + advanceEventTime(durationMillis = 700L) + moveBy( + Offset( + x = 0f, + y = -lockDragDistancePx, + ), + ) + up() + } + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + moveBy( + Offset( + x = -cancelDragDistancePx, + y = 0f, + ), + ) + up() + } + + composeTestRule.runOnIdle { + assertEquals(0, finishRequests) + assertEquals(1, cancelRequests) + assertEquals(ConversationAudioRecordingPhase.Idle, audioRecording.phase) + } + } + + private fun setContent( + audioRecording: ConversationAudioRecordingUiState = ConversationAudioRecordingUiState(), + messageText: String, + subjectText: String = "", + sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, + segmentCounter: ConversationSegmentCounterUiState? = null, + isMessageFieldEnabled: Boolean = true, + isSendActionEnabled: Boolean = true, + isAttachmentActionEnabled: Boolean = false, + isRecordActionEnabled: Boolean = true, + shouldShowRecordAction: Boolean = false, + hapticFeedback: HapticFeedback? = null, + onContactAttachClick: () -> Unit = {}, + onMediaPickerClick: () -> Unit = {}, + onLockedAudioRecordingStartRequest: () -> Unit = {}, + onMessageTextChange: (String) -> Unit = {}, + onAudioRecordingStartRequest: () -> Unit = {}, + onAudioRecordingFinish: () -> Unit = {}, + onAudioRecordingLock: () -> Boolean = { false }, + onAudioRecordingCancel: () -> Unit = {}, + onSendClick: () -> Unit = {}, + onSendActionLongClick: () -> Unit = {}, + onSubjectChipClick: () -> Unit = {}, + onSubjectChipClear: () -> Unit = {}, + ) { + setContent( + audioRecording = { audioRecording }, + messageText = { messageText }, + subjectText = subjectText, + sendProtocol = sendProtocol, + segmentCounter = segmentCounter, + isMessageFieldEnabled = isMessageFieldEnabled, + isSendActionEnabled = isSendActionEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + shouldShowRecordAction = { shouldShowRecordAction }, + hapticFeedback = hapticFeedback, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onMessageTextChange = onMessageTextChange, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingCancel = onAudioRecordingCancel, + onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, + ) + } + + private fun setContent( + audioRecording: () -> ConversationAudioRecordingUiState = + { ConversationAudioRecordingUiState() }, + messageText: () -> String, + subjectText: String = "", + sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, + segmentCounter: ConversationSegmentCounterUiState? = null, + isMessageFieldEnabled: Boolean = true, + isSendActionEnabled: Boolean = true, + isAttachmentActionEnabled: Boolean = false, + isRecordActionEnabled: Boolean = true, + shouldShowRecordAction: () -> Boolean = { false }, + hapticFeedback: HapticFeedback? = null, + onContactAttachClick: () -> Unit = {}, + onMediaPickerClick: () -> Unit = {}, + onLockedAudioRecordingStartRequest: () -> Unit = {}, + onMessageTextChange: (String) -> Unit = {}, + onAudioRecordingStartRequest: () -> Unit = {}, + onAudioRecordingFinish: () -> Unit = {}, + onAudioRecordingLock: () -> Boolean = { false }, + onAudioRecordingCancel: () -> Unit = {}, + onSendClick: () -> Unit = {}, + onSendActionLongClick: () -> Unit = {}, + onSubjectChipClick: () -> Unit = {}, + onSubjectChipClear: () -> Unit = {}, + ) { + composeTestRule.setContent { + val content: @Composable () -> Unit = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + ConversationComposeBar( + audioRecording = audioRecording(), + messageText = messageText(), + subjectText = subjectText, + sendProtocol = sendProtocol, + segmentCounter = segmentCounter, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + isSendActionEnabled = isSendActionEnabled, + shouldShowRecordAction = shouldShowRecordAction(), + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onMessageTextChange = onMessageTextChange, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingCancel = onAudioRecordingCancel, + onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, + ) + } + } + + hapticFeedback?.let { feedback -> + CompositionLocalProvider(LocalHapticFeedback provides feedback) { + AppTheme(content = content) + } + } ?: AppTheme(content = content) + } + } + + private fun createHapticFeedbackMock(): HapticFeedback { + val hapticFeedback = mockk() + every { + hapticFeedback.performHapticFeedback(any()) + } just runs + return hapticFeedback + } + + private fun recordingAudioState( + durationMillis: Long = 0L, + isLocked: Boolean = false, + ): ConversationAudioRecordingUiState { + return ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + durationMillis = durationMillis, + isLocked = isLocked, + ) + } + + private fun finalizingAudioState(): ConversationAudioRecordingUiState { + return ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Finalizing, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheetTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheetTest.kt new file mode 100644 index 000000000..f117ee3a1 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheetTest.kt @@ -0,0 +1,130 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.TEST_ATT_SUBSCRIPTION_NAME +import com.android.messaging.ui.conversation.TEST_VERIZON_SUBSCRIPTION_NAME +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.conversationSimSelectorItemTestTag +import com.android.messaging.ui.conversation.testAttSubscription +import com.android.messaging.ui.conversation.testVerizonSubscription +import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationSimSelectorSheetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun sheet_rendersTitleAndAllSubscriptions() { + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testVerizonSubscription, + ), + ) + + val title = targetContext.getString(R.string.sim_selector_sheet_title) + + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG) + .assertIsDisplayed() + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(TEST_VERIZON_SUBSCRIPTION_NAME).assertIsDisplayed() + composeTestRule.onNodeWithText(TEST_ATT_SUBSCRIPTION_NAME).assertIsDisplayed() + composeTestRule + .onNodeWithText(testVerizonSubscription.displayDestination.orEmpty()) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(testAttSubscription.displayDestination.orEmpty()) + .assertIsDisplayed() + } + + @Test + fun sheet_marksSelectedSubscriptionWithCheckIcon() { + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testAttSubscription, + ), + ) + + val selectedDescription = targetContext.getString(R.string.sim_selector_item_selected) + + composeTestRule + .onNodeWithContentDescription(selectedDescription) + .assertIsDisplayed() + } + + @Test + fun sheet_doesNotShowCheckWhenNoSubscriptionIsSelected() { + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = null, + ), + ) + + val selectedDescription = targetContext.getString(R.string.sim_selector_item_selected) + + composeTestRule + .onNodeWithContentDescription(selectedDescription) + .assertDoesNotExist() + } + + @Test + fun sheet_invokesCallbackWithSelfParticipantIdWhenRowClicked() { + val selections = mutableListOf() + + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testVerizonSubscription, + ), + onSimSelected = { selfParticipantId -> + selections += selfParticipantId + }, + ) + + composeTestRule + .onNodeWithTag( + conversationSimSelectorItemTestTag( + selfParticipantId = testAttSubscription.selfParticipantId + ), + ) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(listOf(testAttSubscription.selfParticipantId), selections) + } + } + + private fun setContent( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + ConversationSimSelectorSheet( + uiState = uiState, + onSimSelected = onSimSelected, + onDismissRequest = {}, + ) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt new file mode 100644 index 000000000..6d1ca8e89 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt @@ -0,0 +1,237 @@ +package com.android.messaging.ui.conversation.entry + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import com.android.messaging.ui.conversation.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.entry.model.NewChatUiState +import com.android.messaging.ui.conversation.newChatContactRowTestTag +import com.android.messaging.ui.conversation.recipientpicker.component.row.CONTACT_ID +import com.android.messaging.ui.conversation.recipientpicker.component.row.MOBILE_NORMALIZED_DESTINATION +import com.android.messaging.ui.conversation.recipientpicker.component.row.contactItem +import com.android.messaging.ui.conversation.recipientpicker.component.row.selectedRecipient +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState +import com.android.messaging.ui.core.AppTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NewChatScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val defaultContactRowTestTag = newChatContactRowTestTag( + contactId = "contact:$CONTACT_ID", + ) + + @Test + fun contactClick_forwardsNormalizedDestinationToScreenModel() { + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + recipientPickerUiState = RecipientPickerUiState( + items = persistentListOf(contactItem()), + ), + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onNodeWithTag(defaultContactRowTestTag) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onContactClicked(destination = MOBILE_NORMALIZED_DESTINATION) + } + } + } + + @Test + fun queryAndLoadMore_forwardToScreenModel() { + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + recipientPickerUiState = RecipientPickerUiState( + items = persistentListOf( + *Array(size = 30) { index -> + contactItem( + id = index.toLong(), + displayName = "Contact $index", + destination = "+1 555 ${ + index.toString().padStart(length = 4, padChar = '0') + }", + normalizedDestination = "+1555${ + index.toString().padStart(length = 4, padChar = '0') + }", + ) + }, + ), + canLoadMore = true, + ), + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onNode(matcher = hasSetTextAction()) + .performTextInput("Ada") + composeTestRule + .onNode(matcher = hasScrollToIndexAction()) + .performScrollToIndex(index = 30) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onQueryChanged(query = "Ada") + screenModel.onLoadMore() + } + } + } + + @Test + fun createGroupButton_forwardsRequestToScreenModel() { + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + recipientPickerUiState = RecipientPickerUiState( + items = persistentListOf(contactItem()), + ), + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onCreateGroupRequested() + } + } + } + + @Test + fun createGroupMode_togglesRecipientsAndConfirmsSelection() { + val selectedRecipient = selectedRecipient() + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + isCreatingGroup = true, + recipientPickerUiState = RecipientPickerUiState( + items = persistentListOf(contactItem()), + ), + selectedGroupRecipients = persistentListOf(selectedRecipient), + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onNodeWithTag(defaultContactRowTestTag) + .assertIsSelected() + .performClick() + composeTestRule + .onNodeWithTag(NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onCreateGroupRecipientClicked(recipient = selectedRecipient) + screenModel.onCreateGroupConfirmed() + } + } + } + + @Test + fun longPress_forwardsSelectedRecipientToScreenModel() { + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + recipientPickerUiState = RecipientPickerUiState( + items = persistentListOf(contactItem()), + ), + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onNodeWithTag(defaultContactRowTestTag) + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onContactLongClicked(recipient = selectedRecipient()) + } + } + } + + @Test + fun resolvingState_showsRowProgressIndicatorOnlyForMatchingRecipient() { + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + isResolvingConversation = true, + isResolvingConversationIndicatorVisible = true, + recipientPickerUiState = RecipientPickerUiState( + items = persistentListOf( + contactItem( + id = 1, + normalizedDestination = MOBILE_NORMALIZED_DESTINATION, + ), + contactItem( + id = 2, + displayName = "Grace Hopper", + destination = "+1 555 0200", + normalizedDestination = "+15550200", + ), + ), + ), + resolvingRecipientDestination = MOBILE_NORMALIZED_DESTINATION, + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onAllNodesWithTag(NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG) + .assertCountEquals(expectedSize = 1) + } + + private fun setContent(screenModel: NewChatScreenModel) { + composeTestRule.setContent { + AppTheme { + NewChatScreen(screenModel = screenModel) + } + } + } + + private fun createScreenModel(initialUiState: NewChatUiState): NewChatScreenModel { + return mockk(relaxed = true) { + every { effects } returns MutableSharedFlow() + every { uiState } returns MutableStateFlow(value = initialUiState) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarSimSelectorTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarSimSelectorTest.kt new file mode 100644 index 000000000..58ab6aae0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarSimSelectorTest.kt @@ -0,0 +1,201 @@ +package com.android.messaging.ui.conversation.metadata.ui + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.TEST_ATT_SUBSCRIPTION_NAME +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.testAttSubscription +import com.android.messaging.ui.conversation.testVerizonSubscription +import com.android.messaging.ui.core.AppTheme +import com.android.messaging.util.AccessibilityUtil +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationTopAppBarSimSelectorTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun simSelectorMenuItem_isHiddenWhenOnlyOneSubscriptionIsAvailable() { + setContent( + simSelector = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription), + selectedSubscription = testVerizonSubscription, + ), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun simSelectorMenuItem_showsSelectedSubscriptionLabelWhenExpanded() { + setContent( + simSelector = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testAttSubscription, + ), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .performClick() + + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(TEST_ATT_SUBSCRIPTION_NAME) + .assertIsDisplayed() + } + + @Test + fun simSelectorMenuItem_clickInvokesCallbackAndDismissesMenu() { + var clicks = 0 + + setContent( + simSelector = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testVerizonSubscription, + ), + onSimSelectorClick = { clicks += 1 }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .performClick() + + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun oneOnOneConversation_showsDisplayDestinationSubtitleAndAccessibilityLabel() { + setContent(simSelector = ConversationSimSelectorUiState()) + + composeTestRule + .onAllNodesWithText("(555) 123-4567") + .assertCountEquals(expectedSize = 1) + composeTestRule + .onNodeWithText(TEST_CALL_ACTION_PHONE_NUMBER) + .assertDoesNotExist() + + val expectedContentDescription = getVocalizedPhoneNumber(phoneNumber = "(555) 123-4567") + + composeTestRule + .onNodeWithContentDescription(expectedContentDescription) + .assertIsDisplayed() + } + + @Test + fun oneOnOneConversation_hidesSubtitleWhenItMatchesTitle() { + setContent( + metadata = presentMetadata.copy( + title = "(555) 123-4567", + ), + ) + + composeTestRule + .onAllNodesWithText("(555) 123-4567") + .assertCountEquals(expectedSize = 1) + } + + @Test + fun groupConversation_showsParticipantCountSubtitle() { + val participantCountText = targetContext.resources.getQuantityString( + R.plurals.wearable_participant_count, + 3, + 3, + ) + + setContent( + metadata = ConversationMetadataUiState.Present( + title = "Weekend plan", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Group, + participantCount = 3, + otherParticipantDisplayDestination = null, + otherParticipantPhoneNumber = null, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ), + ) + + composeTestRule + .onNodeWithText(participantCountText) + .assertIsDisplayed() + } + + private fun setContent( + metadata: ConversationMetadataUiState = presentMetadata, + simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), + onSimSelectorClick: () -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + ConversationTopAppBar( + metadata = metadata, + simSelector = simSelector, + onAddPeopleClick = {}, + onSimSelectorClick = onSimSelectorClick, + onTitleClick = {}, + onNavigateBack = {}, + ) + } + } + } + + @Suppress("SameParameterValue") + private fun getVocalizedPhoneNumber(phoneNumber: String): String { + return AccessibilityUtil.getVocalizedPhoneNumber( + targetContext.resources, + phoneNumber, + ) + } + + private companion object { + private val presentMetadata = ConversationMetadataUiState.Present( + title = "Carol", + selfParticipantId = "self-1", + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ), + participantCount = 1, + otherParticipantDisplayDestination = "(555) 123-4567", + otherParticipantPhoneNumber = TEST_CALL_ACTION_PHONE_NUMBER, + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt new file mode 100644 index 000000000..b2c04daf6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt @@ -0,0 +1,371 @@ +package com.android.messaging.ui.conversation.metadata.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.ui.conversation.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_CALL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.core.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationTopAppBarTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun title_whenMetadataPresent_forwardsClick() { + var clicks = 0 + + setContent(onTitleClick = { clicks += 1 }) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun title_whenMetadataNotPresent_doesNotForwardClick() { + var clicks = 0 + + setContent( + metadata = ConversationMetadataUiState.Loading, + onTitleClick = { clicks += 1 }, + ) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(0, clicks) + } + } + + @Test + fun title_whenTitleIsBlank_fallsBackToAppName() { + val appName = targetContext.getString(R.string.app_name) + + setContent( + metadata = presentMetadata.copy(title = ""), + ) + + composeTestRule + .onNodeWithText(appName) + .assertIsDisplayed() + } + + @Test + fun loadingMetadata_showsFallbackTitleAndLoadingSubtitle() { + setContent(metadata = ConversationMetadataUiState.Loading) + + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.app_name)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.loading_messages)) + .assertIsDisplayed() + } + + @Test + fun unavailableMetadata_showsFallbackTitleWithoutLoadingSubtitle() { + setContent(metadata = ConversationMetadataUiState.Unavailable) + + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.app_name)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.loading_messages)) + .assertDoesNotExist() + } + + @Test + fun oneOnOneContact_hidesDestinationSubtitle() { + val displayDestination = checkNotNull(presentMetadata.otherParticipantDisplayDestination) + + setContent( + metadata = presentMetadata.copy( + otherParticipantContactLookupKey = "lookup-key", + ), + ) + + composeTestRule + .onNodeWithText(text = displayDestination) + .assertDoesNotExist() + } + + @Test + fun singleAvatarWithPhotoUri_rendersConversationTitle() { + setContent( + metadata = presentMetadata.copy( + avatar = ConversationMetadataUiState.Avatar.Single( + photoUri = "content://conversation/avatar", + ), + ), + ) + + composeTestRule + .onNodeWithText(text = presentMetadata.title) + .assertIsDisplayed() + } + + @Test + fun navigationIcon_forwardsBackClick() { + var clicks = 0 + val backDescription = targetContext.getString(R.string.back) + + setContent(onNavigateBack = { clicks += 1 }) + + composeTestRule + .onNodeWithContentDescription(backDescription) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun callButton_whenVisible_forwardsClick() { + var clicks = 0 + + setContent( + isCallVisible = true, + onCallClick = { clicks += 1 }, + ) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_CALL_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun callButton_whenNotVisible_isHidden() { + setContent(isCallVisible = false) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_CALL_BUTTON_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun overflowMenu_isHiddenWhenNoSecondaryActionsAvailable() { + setContent() + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun addPeopleMenuItem_isShownInOverflow_andForwardsClickAndDismissesMenu() { + var clicks = 0 + + setContent( + isAddPeopleVisible = true, + onAddPeopleClick = { clicks += 1 }, + ) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG) + .assertDoesNotExist() + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun addContactMenuItem_forwardsClick() { + var clicks = 0 + + setContent( + isAddContactVisible = true, + onAddContactClick = { clicks += 1 }, + ) + + openOverflowMenuAndClickItem(menuItemTestTag = CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG) + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun showSubjectFieldMenuItem_forwardsClick() { + var clicks = 0 + + setContent( + isShowSubjectFieldVisible = true, + onShowSubjectFieldClick = { clicks += 1 }, + ) + + openOverflowMenuAndClickItem( + menuItemTestTag = CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG, + ) + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun archiveMenuItem_forwardsClick() { + var clicks = 0 + + setContent( + isArchiveVisible = true, + onArchiveClick = { clicks += 1 }, + ) + + openOverflowMenuAndClickItem(menuItemTestTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG) + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun unarchiveMenuItem_forwardsClick() { + var clicks = 0 + + setContent( + isUnarchiveVisible = true, + onUnarchiveClick = { clicks += 1 }, + ) + + openOverflowMenuAndClickItem(menuItemTestTag = CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG) + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun deleteConversationMenuItem_forwardsClick() { + var clicks = 0 + + setContent( + isDeleteConversationVisible = true, + onDeleteConversationClick = { clicks += 1 }, + ) + + openOverflowMenuAndClickItem( + menuItemTestTag = CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG, + ) + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + } + + private fun openOverflowMenuAndClickItem(menuItemTestTag: String) { + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .performClick() + + composeTestRule + .onNodeWithTag(testTag = menuItemTestTag) + .performClick() + } + + private fun setContent( + metadata: ConversationMetadataUiState = presentMetadata, + isCallVisible: Boolean = false, + isAddPeopleVisible: Boolean = false, + isArchiveVisible: Boolean = false, + isUnarchiveVisible: Boolean = false, + isAddContactVisible: Boolean = false, + isDeleteConversationVisible: Boolean = false, + isShowSubjectFieldVisible: Boolean = false, + onCallClick: () -> Unit = {}, + onAddPeopleClick: () -> Unit = {}, + onArchiveClick: () -> Unit = {}, + onUnarchiveClick: () -> Unit = {}, + onAddContactClick: () -> Unit = {}, + onDeleteConversationClick: () -> Unit = {}, + onShowSubjectFieldClick: () -> Unit = {}, + onTitleClick: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + ) { + composeTestRule.setContent { + AppTheme { + ConversationTopAppBar( + metadata = metadata, + isCallVisible = isCallVisible, + isAddPeopleVisible = isAddPeopleVisible, + isArchiveVisible = isArchiveVisible, + isUnarchiveVisible = isUnarchiveVisible, + isAddContactVisible = isAddContactVisible, + isDeleteConversationVisible = isDeleteConversationVisible, + isShowSubjectFieldVisible = isShowSubjectFieldVisible, + onCallClick = onCallClick, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, + onTitleClick = onTitleClick, + onNavigateBack = onNavigateBack, + ) + } + } + } + + private companion object { + private val presentMetadata = ConversationMetadataUiState.Present( + title = "Carol", + selfParticipantId = "self-participant-id", + avatar = ConversationMetadataUiState.Avatar.Single(photoUri = null), + participantCount = 1, + otherParticipantDisplayDestination = "+372 5440 0024", + otherParticipantPhoneNumber = "+37254400024", + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.Editable, + ) + } +} From 36d174c677b867526a52d28780e58a4aca9a7915 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:14:49 +0300 Subject: [PATCH 19/38] Cover conversation media picker UI --- ...ConversationMediaPickerCaptureRouteTest.kt | 236 ++++++++++++++++++ .../ConversationMediaPickerScaffoldTest.kt | 101 ++++++++ ...ndConversationCameraLifecycleEffectTest.kt | 128 ++++++++++ .../ConversationMediaCaptureContentTest.kt | 147 +++++++++++ .../ConversationMediaCaptureControlsTest.kt | 130 ++++++++++ ...nversationMediaCaptureShutterButtonTest.kt | 75 ++++++ .../ConversationMediaCaptureTopBarTest.kt | 119 +++++++++ ...onversationMediaPickerReviewCaptionTest.kt | 84 +++++++ ...rsationMediaPickerReviewInteractionTest.kt | 191 ++++++++++++++ .../ConversationMediaPickerReviewPagerTest.kt | 161 ++++++++++++ ...versationMediaPickerReviewRenderingTest.kt | 139 +++++++++++ .../ConversationMediaReviewBackgroundTest.kt | 136 ++++++++++ ...nversationMediaPickerSharedControlsTest.kt | 158 ++++++++++++ .../ui/conversation/ConversationTestTags.kt | 6 + .../ConversationMediaCaptureControls.kt | 4 + .../ConversationMediaCaptureShutterButton.kt | 7 +- .../review/ConversationMediaReviewPageCard.kt | 9 +- 17 files changed, 1829 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRouteTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffoldTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/BindConversationCameraLifecycleEffectTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureContentTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControlsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButtonTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureTopBarTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewCaptionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewInteractionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewPagerTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/shared/ConversationMediaPickerSharedControlsTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRouteTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRouteTest.kt new file mode 100644 index 000000000..b17454e89 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRouteTest.kt @@ -0,0 +1,236 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.media.model.ConversationCapturedMedia +import io.mockk.every +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerCaptureRouteTest : BaseConversationMediaPickerTest() { + + @Test + fun closeWhileIdle_closesWithoutCancellingRecording() { + setCaptureRouteContent() + + closeButton().performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onClose.invoke() + } + verify(exactly = 0) { + cameraController.cancelVideoRecording() + } + } + } + + @Test + fun closeWhileRecording_cancelsRecordingBeforeClosing() { + cameraState.isRecording.value = true + + setCaptureRouteContent( + captureMode = ConversationCaptureMode.Video, + ) + + closeButton().performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.cancelVideoRecording() + } + verify(exactly = 1) { + onClose.invoke() + } + } + } + + @Test + fun photoCapture_whenAttachmentStartRejectedDoesNotCapture() { + every { onAttachmentStartRequest.invoke() } returns false + + setCaptureRouteContent( + captureMode = ConversationCaptureMode.Photo, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentStartRequest.invoke() + } + verify(exactly = 0) { + cameraController.capturePhoto( + onCaptured = any(), + onError = any(), + ) + } + } + } + + @Test + fun photoCapture_successPublishesCapturedMediaAndShowsReview() { + val capturedPhoto = capturedPhoto() + every { + cameraController.capturePhoto( + onCaptured = any(), + onError = any(), + ) + } answers { + arg<(ConversationCapturedMedia) -> Unit>(0).invoke(capturedPhoto) + } + + setCaptureRouteContent( + captureMode = ConversationCaptureMode.Photo, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.capturePhoto( + onCaptured = any(), + onError = any(), + ) + } + verify(exactly = 1) { + onCapturedMediaReady.invoke(capturedPhoto) + } + verify(exactly = 1) { + onShowReview.invoke(CAPTURED_PHOTO_URI) + } + } + } + + @Test + fun videoCaptureWhileRecording_stopsRecordingWithoutStartingAttachment() { + cameraState.isRecording.value = true + + setCaptureRouteContent( + captureMode = ConversationCaptureMode.Video, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.stopVideoRecording() + } + verify(exactly = 0) { + onAttachmentStartRequest.invoke() + } + verify(exactly = 0) { + cameraController.startVideoRecording( + withAudio = any(), + onCaptured = any(), + onDiscarded = any(), + onError = any(), + ) + } + } + } + + @Test + fun videoCapture_whenAttachmentStartRejectedDoesNotStartRecording() { + every { onAttachmentStartRequest.invoke() } returns false + + setCaptureRouteContent( + captureMode = ConversationCaptureMode.Video, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentStartRequest.invoke() + } + verify(exactly = 0) { + cameraController.startVideoRecording( + withAudio = any(), + onCaptured = any(), + onDiscarded = any(), + onError = any(), + ) + } + } + } + + @Test + fun videoCapture_successPublishesCapturedMediaAndShowsReview() { + val capturedVideo = capturedVideo() + every { + cameraController.startVideoRecording( + withAudio = true, + onCaptured = any(), + onDiscarded = any(), + onError = any(), + ) + } answers { + arg<(ConversationCapturedMedia) -> Unit>(1).invoke(capturedVideo) + } + + setCaptureRouteContent( + captureMode = ConversationCaptureMode.Video, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.startVideoRecording( + withAudio = true, + onCaptured = any(), + onDiscarded = any(), + onError = any(), + ) + } + verify(exactly = 1) { + onCapturedMediaReady.invoke(capturedVideo) + } + verify(exactly = 1) { + onShowReview.invoke(CAPTURED_VIDEO_URI) + } + } + } + + @Test + fun switchCameraAndFlash_delegateToCameraController() { + cameraState.hasFlashUnit.value = true + + setCaptureRouteContent() + + composeTestRule + .onNodeWithContentDescription( + targetContext.getString(R.string.camera_switch_camera_facing) + ) + .performClick() + composeTestRule + .onNodeWithContentDescription( + targetContext.getString( + R.string.conversation_media_picker_cycle_flash_mode_content_description, + ), + ) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.switchCamera(onError = any()) + } + verify(exactly = 1) { + cameraController.cyclePhotoFlashMode(onError = any()) + } + } + } + + private fun closeButton(): SemanticsNodeInteraction { + return composeTestRule.onNodeWithContentDescription( + targetContext.getString(R.string.conversation_media_picker_close_content_description), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffoldTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffoldTest.kt new file mode 100644 index 000000000..e49d762f6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffoldTest.kt @@ -0,0 +1,101 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerScaffoldTest : BaseConversationMediaPickerTest() { + + @Test + fun captureModeRendersCameraPermissionFallbackAndForwardsPermissionAction() { + setScaffoldContent( + isReviewVisible = false, + cameraPermissionGranted = false, + ) + + composeTestRule + .onNodeWithText(string(R.string.conversation_media_picker_camera_permission_message)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(string(R.string.conversation_media_picker_allow_camera)) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onRequestCameraPermission.invoke() + } + verify(exactly = 0) { + onClearReview.invoke() + } + } + } + + @Test + fun reviewModeRendersAttachmentReviewInsteadOfCaptureFallback() { + setScaffoldContent( + isReviewVisible = true, + cameraPermissionGranted = false, + ) + + composeTestRule + .onNodeWithText(IMAGE_CAPTION) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(string(R.string.conversation_media_picker_camera_permission_message)) + .assertDoesNotExist() + } + + @Test + fun reviewModeSend_forwardsSendAndClosesPicker() { + setScaffoldContent( + isReviewVisible = true, + ) + + composeTestRule + .onNodeWithContentDescription(string(R.string.sendButtonContentDescription)) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onSendClick.invoke() + } + verify(exactly = 1) { + onClose.invoke() + } + } + } + + @Test + fun reviewModeAddMore_clearsReview() { + setScaffoldContent( + isReviewVisible = true, + ) + + composeTestRule + .onNodeWithContentDescription( + string(R.string.conversation_media_picker_add_more_content_description), + ) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onClearReview.invoke() + } + verify(exactly = 0) { + onClose.invoke() + } + } + } + + private fun string(resourceId: Int): String { + return targetContext.getString(resourceId) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/BindConversationCameraLifecycleEffectTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/BindConversationCameraLifecycleEffectTest.kt new file mode 100644 index 000000000..5c00f3af0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/camera/BindConversationCameraLifecycleEffectTest.kt @@ -0,0 +1,128 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.lifecycle.LifecycleOwner +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class BindConversationCameraLifecycleEffectTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val cameraController = mockk(relaxed = true) + private val lifecycleOwner = mockk() + + @Before + fun setUpBindConversationCameraLifecycleEffectTest() { + clearAllMocks() + } + + @Test + fun permissionGrantedAndPreviewVisible_bindsCameraAndUnbindsWhenOwnerChanges() { + val firstLifecycleOwner = lifecycleOwner + val secondLifecycleOwner = mockk() + var currentLifecycleOwner by mutableStateOf(firstLifecycleOwner) + + composeTestRule.setContent { + BindConversationCameraLifecycleEffect( + cameraController = cameraController, + cameraPermissionGranted = true, + isCameraPreviewVisible = true, + lifecycleOwner = currentLifecycleOwner, + ) + } + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.bindToLifecycle( + lifecycleOwner = firstLifecycleOwner, + onError = any(), + ) + } + verify(exactly = 0) { + cameraController.unbind() + } + } + + composeTestRule.runOnIdle { + currentLifecycleOwner = secondLifecycleOwner + } + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.unbind() + } + verify(exactly = 1) { + cameraController.bindToLifecycle( + lifecycleOwner = secondLifecycleOwner, + onError = any(), + ) + } + } + } + + @Test + fun permissionDenied_unbindsWithoutBinding() { + setContent( + cameraPermissionGranted = false, + isCameraPreviewVisible = true, + ) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.unbind() + } + verify(exactly = 0) { + cameraController.bindToLifecycle( + lifecycleOwner = any(), + onError = any(), + ) + } + } + } + + @Test + fun previewHidden_unbindsWithoutBinding() { + setContent( + cameraPermissionGranted = true, + isCameraPreviewVisible = false, + ) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + cameraController.unbind() + } + verify(exactly = 0) { + cameraController.bindToLifecycle( + lifecycleOwner = any(), + onError = any(), + ) + } + } + } + + private fun setContent( + cameraPermissionGranted: Boolean, + isCameraPreviewVisible: Boolean, + ) { + composeTestRule.setContent { + BindConversationCameraLifecycleEffect( + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + isCameraPreviewVisible = isCameraPreviewVisible, + lifecycleOwner = lifecycleOwner, + ) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureContentTest.kt new file mode 100644 index 000000000..cca1591cc --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureContentTest.kt @@ -0,0 +1,147 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaCaptureContentTest : + BaseConversationMediaCaptureComponentTest() { + + @Test + fun previewSurface_cameraDeniedRendersPermissionFallbackAndForwardsAction() { + setPreviewSurfaceContent(cameraPermissionGranted = false) + + composeTestRule + .onNodeWithText(string(R.string.conversation_media_picker_camera_permission_message)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(string(R.string.conversation_media_picker_allow_camera)) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onRequestCameraPermission.invoke() + } + } + } + + @Test + fun previewSurface_cameraGrantedWithoutSurfaceRendersLoadingState() { + setPreviewSurfaceContent(cameraPermissionGranted = true) + + composeTestRule + .onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(string(R.string.conversation_media_picker_camera_permission_message)) + .assertDoesNotExist() + } + + @Test + fun cameraPermissionDenied_hidesCaptureControlsAndFlash() { + setCaptureContent( + cameraPermissionGranted = false, + hasFlashUnit = true, + ) + + composeTestRule + .onNodeWithContentDescription(closeDescription()) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(flashDescription()) + .assertDoesNotExist() + composeTestRule + .onNodeWithContentDescription(switchCameraDescription()) + .assertDoesNotExist() + composeTestRule + .onNodeWithText(photoModeLabel()) + .assertDoesNotExist() + composeTestRule + .onNodeWithText(videoModeLabel()) + .assertDoesNotExist() + } + + @Test + fun photoCaptureClick_forwardsPhotoCaptureOnly() { + setCaptureContent( + captureMode = ConversationCaptureMode.Photo, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onPhotoCaptureClick.invoke() + } + verify(exactly = 0) { + onVideoCaptureClick.invoke() + } + verify(exactly = 0) { + onRequestAudioPermission.invoke() + } + } + } + + @Test + fun videoCaptureWithoutAudioPermission_requestsAudioOnly() { + setCaptureContent( + audioPermissionGranted = false, + captureMode = ConversationCaptureMode.Video, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onRequestAudioPermission.invoke() + } + verify(exactly = 0) { + onVideoCaptureClick.invoke() + } + verify(exactly = 0) { + onPhotoCaptureClick.invoke() + } + } + } + + @Test + fun videoCaptureWithAudioPermission_forwardsVideoCapture() { + setCaptureContent( + audioPermissionGranted = true, + captureMode = ConversationCaptureMode.Video, + ) + + clickCaptureControl() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onVideoCaptureClick.invoke() + } + verify(exactly = 0) { + onRequestAudioPermission.invoke() + } + verify(exactly = 0) { + onPhotoCaptureClick.invoke() + } + } + } + + private fun clickCaptureControl() { + composeTestRule + .onNodeWithTag(CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG) + .performClick() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControlsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControlsTest.kt new file mode 100644 index 000000000..75548cdb8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControlsTest.kt @@ -0,0 +1,130 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.messaging.testutil.performDisabledTouchClick +import com.android.messaging.testutil.performTouchClick +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaCaptureControlsTest : + BaseConversationMediaCaptureComponentTest() { + + @Test + fun videoModeChip_forwardsVideoModeCallback() { + setControlsContent(captureMode = ConversationCaptureMode.Photo) + + composeTestRule + .onNodeWithText(videoModeLabel()) + .assertIsDisplayed() + .performTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onVideoModeClick.invoke() + } + verify(exactly = 0) { + onPhotoModeClick.invoke() + } + } + } + + @Test + fun photoModeChip_forwardsPhotoModeCallback() { + setControlsContent(captureMode = ConversationCaptureMode.Video) + + composeTestRule + .onNodeWithText(photoModeLabel()) + .assertIsDisplayed() + .performTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onVideoModeClick.invoke() + } + verify(exactly = 1) { + onPhotoModeClick.invoke() + } + } + } + + @Test + fun modeToggleAndSwitchCamera_disabledWhilePhotoCaptureInProgress() { + setControlsContent( + isPhotoCaptureInProgress = true, + ) + + composeTestRule + .onNodeWithText(videoModeLabel()) + .performDisabledTouchClick() + composeTestRule + .onNodeWithContentDescription(switchCameraDescription()) + .assertIsNotEnabled() + .performDisabledTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onVideoModeClick.invoke() + } + verify(exactly = 0) { + onSwitchCameraClick.invoke() + } + } + } + + @Test + fun recordingShowsTimerAndDisablesModeSwitchesAndSwitchCamera() { + setControlsContent( + captureMode = ConversationCaptureMode.Video, + isRecording = true, + recordingDurationMillis = RECORDING_DURATION_MILLIS, + ) + + composeTestRule + .onNodeWithText(RECORDING_DURATION_TEXT) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(photoModeLabel()) + .performDisabledTouchClick() + composeTestRule + .onNodeWithContentDescription(switchCameraDescription()) + .assertIsNotEnabled() + .performDisabledTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onPhotoModeClick.invoke() + } + verify(exactly = 0) { + onSwitchCameraClick.invoke() + } + } + } + + @Test + fun switchCamera_forwardsClickWhenIdle() { + setControlsContent() + + composeTestRule + .onNodeWithContentDescription(switchCameraDescription()) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onSwitchCameraClick.invoke() + } + } + } + + private companion object { + private const val RECORDING_DURATION_MILLIS = 65_000L + private const val RECORDING_DURATION_TEXT = "01:05" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButtonTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButtonTest.kt new file mode 100644 index 000000000..20e26269d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButtonTest.kt @@ -0,0 +1,75 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaCaptureShutterButtonTest : + BaseConversationMediaCaptureComponentTest() { + + @Test + fun photoIdle_forwardsCaptureClick() { + setShutterButtonContent( + captureMode = ConversationCaptureMode.Photo, + ) + + clickCaptureShutterButton() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onCaptureClick.invoke() + } + } + } + + @Test + fun photoCaptureInProgress_ignoresCaptureClick() { + setShutterButtonContent( + captureMode = ConversationCaptureMode.Photo, + isPhotoCaptureInProgress = true, + ) + + clickCaptureShutterButton() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onCaptureClick.invoke() + } + } + } + + @Test + fun videoIdle_forwardsCaptureClick() { + setShutterButtonContent( + captureMode = ConversationCaptureMode.Video, + isRecording = false, + ) + + clickCaptureShutterButton() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onCaptureClick.invoke() + } + } + } + + @Test + fun videoRecording_forwardsCaptureClick() { + setShutterButtonContent( + captureMode = ConversationCaptureMode.Video, + isRecording = true, + ) + + clickCaptureShutterButton() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onCaptureClick.invoke() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureTopBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureTopBarTest.kt new file mode 100644 index 000000000..2f88fe5c7 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureTopBarTest.kt @@ -0,0 +1,119 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import com.android.messaging.testutil.performDisabledTouchClick +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaCaptureTopBarTest : + BaseConversationMediaCaptureComponentTest() { + + @Test + fun closeButton_forwardsClick() { + setTopBarContent() + + composeTestRule + .onNodeWithContentDescription(closeDescription()) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onCloseClick.invoke() + } + } + } + + @Test + fun flashHiddenForVideoMode() { + setTopBarContent( + captureMode = ConversationCaptureMode.Video, + hasFlashUnit = true, + ) + + composeTestRule + .onNodeWithContentDescription(flashDescription()) + .assertDoesNotExist() + } + + @Test + fun flashHiddenWhenFlashUnitUnavailable() { + setTopBarContent( + captureMode = ConversationCaptureMode.Photo, + hasFlashUnit = false, + ) + + composeTestRule + .onNodeWithContentDescription(flashDescription()) + .assertDoesNotExist() + } + + @Test + fun flashButton_forwardsClickWhenPhotoModeIdle() { + setTopBarContent( + captureMode = ConversationCaptureMode.Photo, + hasFlashUnit = true, + photoFlashMode = ConversationPhotoFlashMode.Auto, + ) + + composeTestRule + .onNodeWithContentDescription(flashDescription()) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onFlashClick.invoke() + } + } + } + + @Test + fun flashButton_disabledWhilePhotoCapturing() { + setTopBarContent( + captureMode = ConversationCaptureMode.Photo, + hasFlashUnit = true, + isPhotoCaptureInProgress = true, + photoFlashMode = ConversationPhotoFlashMode.On, + ) + + composeTestRule + .onNodeWithContentDescription(flashDescription()) + .assertIsNotEnabled() + .performDisabledTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onFlashClick.invoke() + } + } + } + + @Test + fun flashButton_disabledWhileRecording() { + setTopBarContent( + captureMode = ConversationCaptureMode.Photo, + hasFlashUnit = true, + isRecording = true, + ) + + composeTestRule + .onNodeWithContentDescription(flashDescription()) + .assertIsNotEnabled() + .performDisabledTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onFlashClick.invoke() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewCaptionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewCaptionTest.kt new file mode 100644 index 000000000..640dd694e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewCaptionTest.kt @@ -0,0 +1,84 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerReviewCaptionTest : BaseConversationMediaPickerReviewTest() { + + @Test + fun captionExternalUpdate_whenNotFocusedRefreshesText() { + var attachments by mutableStateOf( + persistentListOf( + imageAttachment(captionText = ""), + ), + ) + + setReviewContent( + attachments = { attachments }, + ) + + composeTestRule.runOnIdle { + attachments = persistentListOf( + imageAttachment(captionText = REMOTE_CAPTION), + ) + } + + awaitText(REMOTE_CAPTION) + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onCaptionChange.invoke( + IMAGE_CONTENT_URI, + REMOTE_CAPTION, + ) + } + } + } + + @Test + fun captionExternalUpdate_doesNotOverwriteFocusedEdit() { + var attachments by mutableStateOf( + persistentListOf( + imageAttachment(captionText = ""), + ), + ) + + setReviewContent( + attachments = { attachments }, + ) + + captionTextField() + .performTextInput(LOCAL_CAPTION) + + composeTestRule.runOnIdle { + attachments = persistentListOf( + imageAttachment(captionText = REMOTE_CAPTION), + ) + } + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(LOCAL_CAPTION) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(REMOTE_CAPTION) + .assertCountEquals(expectedSize = 0) + } + + private companion object { + const val LOCAL_CAPTION = "Local caption draft" + const val REMOTE_CAPTION = "Remote caption" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewInteractionTest.kt new file mode 100644 index 000000000..6b9d19b51 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewInteractionTest.kt @@ -0,0 +1,191 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerReviewInteractionTest : + BaseConversationMediaPickerReviewTest() { + + @Test + fun reviewActions_forwardCallbacks() { + val imageAttachment = imageAttachment() + + setReviewContent( + attachments = persistentListOf(imageAttachment), + ) + + composeTestRule + .onNodeWithContentDescription(closeLabel()) + .performClick() + composeTestRule + .onNodeWithContentDescription(addMoreLabel()) + .performClick() + composeTestRule + .onNodeWithContentDescription(sendLabel()) + .performClick() + clickReviewPageCenter() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onCloseClick.invoke() + } + verify(exactly = 1) { + onAddMoreClick.invoke() + } + verify(exactly = 1) { + onSendClick.invoke() + } + verify(exactly = 1) { + onAttachmentPreviewClick.invoke(imageAttachment) + } + } + } + + @Test + fun captionEdit_forwardsCurrentAttachmentUriAndLatestText() { + val capturedCaptions = mutableListOf() + + setReviewContent( + attachments = persistentListOf( + imageAttachment(captionText = ""), + ), + ) + + captionTextField() + .performTextReplacement(text = "Draft caption") + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onCaptionChange.invoke( + IMAGE_CONTENT_URI, + capture(capturedCaptions), + ) + } + assertEquals( + "Draft caption", + capturedCaptions.last(), + ) + } + } + + @Test + fun removingCurrentAttachment_waitsForExitAnimationBeforeCallback() { + composeTestRule.mainClock.autoAdvance = false + + setReviewContent( + attachments = threeAttachments(), + initiallyReviewedContentUri = VIDEO_CONTENT_URI, + ) + composeTestRule.mainClock.advanceTimeBy(milliseconds = REVIEW_CLOCK_SETTLE_MILLIS) + + composeTestRule + .onNodeWithContentDescription(removeLabel()) + .performClick() + composeTestRule.mainClock.advanceTimeBy( + milliseconds = PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS.toLong() - 1L, + ) + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onAttachmentRemove.invoke(any()) + } + verify(exactly = 0) { + onClearReview.invoke() + } + } + + composeTestRule.mainClock.advanceTimeBy(milliseconds = 1L) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentRemove.invoke(VIDEO_CONTENT_URI) + } + verify(exactly = 0) { + onClearReview.invoke() + } + } + } + + @Test + fun pendingRemoval_disablesPreviewClick() { + composeTestRule.mainClock.autoAdvance = false + + setReviewContent( + attachments = persistentListOf(imageAttachment()), + ) + composeTestRule.mainClock.advanceTimeBy(milliseconds = REVIEW_CLOCK_SETTLE_MILLIS) + + composeTestRule + .onNodeWithContentDescription(removeLabel()) + .performClick() + clickReviewPageCenter() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onAttachmentPreviewClick.invoke(any()) + } + } + } + + @Test + fun removingOnlyAttachment_clearsReviewAfterRemoval() { + composeTestRule.mainClock.autoAdvance = false + + setReviewContent( + attachments = persistentListOf(imageAttachment()), + ) + composeTestRule.mainClock.advanceTimeBy(milliseconds = REVIEW_CLOCK_SETTLE_MILLIS) + + composeTestRule + .onNodeWithContentDescription(removeLabel()) + .performClick() + composeTestRule.mainClock.advanceTimeBy( + milliseconds = PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS.toLong() + + REVIEW_REMOVAL_CALLBACK_MARGIN_MILLIS, + ) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentRemove.invoke(IMAGE_CONTENT_URI) + } + verify(exactly = 1) { + onClearReview.invoke() + } + } + } + + private fun closeLabel(): String { + return targetContext.getString(R.string.conversation_media_picker_close_content_description) + } + + private fun addMoreLabel(): String { + return targetContext.getString( + R.string.conversation_media_picker_add_more_content_description, + ) + } + + private fun sendLabel(): String { + return targetContext.getString(R.string.sendButtonContentDescription) + } + + private fun removeLabel(): String { + return targetContext.getString( + R.string.conversation_media_picker_remove_attachment_content_description, + ) + } + + private companion object { + private const val REVIEW_CLOCK_SETTLE_MILLIS = 200L + private const val REVIEW_REMOVAL_CALLBACK_MARGIN_MILLIS = 10L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewPagerTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewPagerTest.kt new file mode 100644 index 000000000..f739203c4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewPagerTest.kt @@ -0,0 +1,161 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerReviewPagerTest : BaseConversationMediaPickerReviewTest() { + + @Test + fun multipleAttachments_startsAtRequestedContentUri() { + setReviewContent( + attachments = threeAttachments(), + initiallyReviewedContentUri = VIDEO_CONTENT_URI, + ) + + composeTestRule + .onNodeWithText(VIDEO_CAPTION) + .assertIsDisplayed() + } + + @Test + fun initialReview_usesPhotoPickerSourceMapping() { + setReviewContent( + attachments = threeAttachments(), + initiallyReviewedContentUri = PHOTO_PICKER_SOURCE_URI, + photoPickerSourceContentUriByAttachmentContentUri = persistentMapOf( + VIDEO_CONTENT_URI to PHOTO_PICKER_SOURCE_URI, + ), + ) + + composeTestRule + .onNodeWithText(VIDEO_CAPTION) + .assertIsDisplayed() + } + + @Test + fun initialReview_missingUri_fallsBackToLastAttachment() { + setReviewContent( + attachments = threeAttachments(), + initiallyReviewedContentUri = "content://missing/review/uri", + ) + + composeTestRule + .onNodeWithText(LAST_IMAGE_CAPTION) + .assertIsDisplayed() + } + + @Test + fun reviewRequestSequenceChange_scrollsToRequestedAttachment() { + var initiallyReviewedContentUri by mutableStateOf(FIRST_IMAGE_CONTENT_URI) + var reviewRequestSequence by mutableIntStateOf(1) + + setReviewContent( + attachments = { threeAttachments() }, + initiallyReviewedContentUri = { initiallyReviewedContentUri }, + reviewRequestSequence = { reviewRequestSequence }, + ) + + composeTestRule + .onNodeWithText(FIRST_IMAGE_CAPTION) + .assertIsDisplayed() + + composeTestRule.runOnIdle { + initiallyReviewedContentUri = VIDEO_CONTENT_URI + reviewRequestSequence += 1 + } + + awaitText(VIDEO_CAPTION) + } + + @Test + fun reviewRequestSequenceChange_usesPhotoPickerSourceMapping() { + var initiallyReviewedContentUri by mutableStateOf(FIRST_IMAGE_CONTENT_URI) + var reviewRequestSequence by mutableIntStateOf(1) + + setReviewContent( + attachments = { threeAttachments() }, + initiallyReviewedContentUri = { initiallyReviewedContentUri }, + reviewRequestSequence = { reviewRequestSequence }, + photoPickerSourceContentUriByAttachmentContentUri = { + persistentMapOf( + VIDEO_CONTENT_URI to PHOTO_PICKER_SOURCE_URI, + ) + }, + ) + + composeTestRule + .onNodeWithText(FIRST_IMAGE_CAPTION) + .assertIsDisplayed() + + composeTestRule.runOnIdle { + initiallyReviewedContentUri = PHOTO_PICKER_SOURCE_URI + reviewRequestSequence += 1 + } + + awaitText(VIDEO_CAPTION) + } + + @Test + fun initialUriChangeWithoutSequenceChange_keepsCurrentAttachment() { + var initiallyReviewedContentUri by mutableStateOf(FIRST_IMAGE_CONTENT_URI) + + setReviewContent( + attachments = { threeAttachments() }, + initiallyReviewedContentUri = { initiallyReviewedContentUri }, + reviewRequestSequence = { 1 }, + ) + + composeTestRule + .onNodeWithText(FIRST_IMAGE_CAPTION) + .assertIsDisplayed() + + composeTestRule.runOnIdle { + initiallyReviewedContentUri = LAST_IMAGE_CONTENT_URI + } + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithText(FIRST_IMAGE_CAPTION) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(LAST_IMAGE_CAPTION) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun attachmentsShrink_clampsCurrentPageToRemainingAttachment() { + val firstAttachment = imageAttachment( + key = FIRST_IMAGE_KEY, + contentUri = FIRST_IMAGE_CONTENT_URI, + captionText = FIRST_IMAGE_CAPTION, + ) + var attachments by mutableStateOf(threeAttachments()) + + setReviewContent( + attachments = { attachments }, + initiallyReviewedContentUri = { LAST_IMAGE_CONTENT_URI }, + ) + + composeTestRule + .onNodeWithText(LAST_IMAGE_CAPTION) + .assertIsDisplayed() + + composeTestRule.runOnIdle { + attachments = persistentListOf(firstAttachment) + } + + awaitText(FIRST_IMAGE_CAPTION) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewRenderingTest.kt new file mode 100644 index 000000000..f5b9a2dfe --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReviewRenderingTest.kt @@ -0,0 +1,139 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.testutil.performDisabledTouchClick +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerReviewRenderingTest : + BaseConversationMediaPickerReviewTest() { + + @Test + fun emptyAttachments_rendersNoReviewControls() { + setReviewContent( + attachments = persistentListOf(), + ) + + composeTestRule + .onAllNodesWithContentDescription(closeLabel()) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onAllNodesWithContentDescription(addMoreLabel()) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onAllNodesWithContentDescription(sendLabel()) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onAllNodesWithText(captionHint()) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun singleImage_rendersTitleCaptionAndActions() { + setReviewContent( + attachments = persistentListOf(imageAttachment()), + conversationTitle = CONVERSATION_TITLE, + ) + + composeTestRule + .onNodeWithText(CONVERSATION_TITLE) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(IMAGE_CAPTION) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(closeLabel()) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(addMoreLabel()) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(sendLabel()) + .assertIsDisplayed() + } + + @Test + fun nullConversationTitle_keepsActionsVisible() { + setReviewContent( + attachments = persistentListOf(imageAttachment(captionText = "")), + conversationTitle = null, + ) + + composeTestRule + .onNodeWithContentDescription(closeLabel()) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(addMoreLabel()) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(captionHint()) + .assertIsDisplayed() + } + + @Test + fun videoAttachment_forwardsPreviewClickWithVideoModel() { + val videoAttachment = videoAttachment() + + setReviewContent( + attachments = persistentListOf(videoAttachment), + ) + + clickReviewPageCenter(contentUri = videoAttachment.contentUri) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentPreviewClick.invoke(videoAttachment) + } + } + } + + @Test + fun disabledSendAction_ignoresTouchClick() { + setReviewContent( + attachments = persistentListOf(imageAttachment()), + isSendActionEnabled = false, + ) + + composeTestRule + .onNodeWithContentDescription( + label = sendLabel(), + useUnmergedTree = true, + ) + .performDisabledTouchClick() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onSendClick.invoke() + } + } + } + + private fun closeLabel(): String { + return targetContext.getString(R.string.conversation_media_picker_close_content_description) + } + + private fun addMoreLabel(): String { + return targetContext.getString( + R.string.conversation_media_picker_add_more_content_description, + ) + } + + private fun sendLabel(): String { + return targetContext.getString(R.string.sendButtonContentDescription) + } + + private fun captionHint(): String { + return targetContext.getString(R.string.conversation_media_picker_caption_hint) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundTest.kt new file mode 100644 index 000000000..a988568d1 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundTest.kt @@ -0,0 +1,136 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color as AndroidColor +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import com.android.common.test.helpers.targetContext +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.GraphicsMode + +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaReviewBackgroundTest : BaseConversationMediaPickerReviewTest() { + + @Test + fun emptyAttachments_rendersFallbackBackground() { + setBackgroundContent( + attachments = persistentListOf(), + ) + + composeTestRule + .onNodeWithTag(BACKGROUND_TAG) + .assertIsDisplayed() + } + + @Test + fun loadableImage_rendersBitmapBackground() { + registerMagentaImageUri() + + setBackgroundContent( + attachments = persistentListOf( + imageAttachment( + contentUri = IMAGE_CONTENT_URI, + ), + ), + ) + + composeTestRule.waitUntil(timeoutMillis = BITMAP_LOAD_TIMEOUT_MILLIS) { + capturedBackgroundHasMagentaPixel() + } + } + + private fun setBackgroundContent( + attachments: ImmutableList, + ) { + composeTestRule.setContent { + ReviewContent { + val pagerState = rememberPagerState( + pageCount = { maxOf(attachments.size, 1) }, + ) + + ConversationMediaReviewBackground( + modifier = Modifier + .fillMaxSize() + .testTag(tag = BACKGROUND_TAG), + pagerState = pagerState, + attachments = attachments, + ) + } + } + } + + private fun registerMagentaImageUri() { + val imageBytes = createMagentaImageBytes() + Shadows + .shadowOf(targetContext.contentResolver) + .registerInputStreamSupplier(Uri.parse(IMAGE_CONTENT_URI)) { + ByteArrayInputStream(imageBytes) + } + } + + private fun createMagentaImageBytes(): ByteArray { + val bitmap = Bitmap.createBitmap( + 24, + 24, + Bitmap.Config.ARGB_8888, + ) + bitmap.eraseColor(AndroidColor.MAGENTA) + + val outputStream = ByteArrayOutputStream() + assertTrue( + bitmap.compress( + Bitmap.CompressFormat.PNG, + 100, + outputStream, + ), + ) + return outputStream.toByteArray() + } + + private fun capturedBackgroundHasMagentaPixel(): Boolean { + val bounds = composeTestRule + .onNodeWithTag(BACKGROUND_TAG) + .fetchSemanticsNode() + .boundsInRoot + val bitmap = composeTestRule.runOnIdle { captureActivityBitmap() } + val centerPixel = bitmap.getPixel( + (bounds.left + bounds.width / 2f).roundToInt(), + (bounds.top + bounds.height / 2f).roundToInt(), + ) + + return AndroidColor.red(centerPixel) > MAGENTA_MINIMUM_COLOR_COMPONENT && + AndroidColor.blue(centerPixel) > MAGENTA_MINIMUM_COLOR_COMPONENT && + AndroidColor.green(centerPixel) < MAGENTA_MAXIMUM_GREEN_COMPONENT + } + + private fun captureActivityBitmap(): Bitmap { + val view = composeTestRule.activity.window.decorView.rootView + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + return bitmap + } + + private companion object { + const val BACKGROUND_TAG = "conversation_media_review_background" + const val BITMAP_LOAD_TIMEOUT_MILLIS = 5_000L + const val MAGENTA_MAXIMUM_GREEN_COMPONENT = 80 + const val MAGENTA_MINIMUM_COLOR_COMPONENT = 80 + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/shared/ConversationMediaPickerSharedControlsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/shared/ConversationMediaPickerSharedControlsTest.kt new file mode 100644 index 000000000..38245aec4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/mediapicker/component/shared/ConversationMediaPickerSharedControlsTest.kt @@ -0,0 +1,158 @@ +package com.android.messaging.ui.conversation.mediapicker.component.shared + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.android.messaging.ui.conversation.mediapicker.component.PermissionFallback +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayBackgroundButton +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayIconButton +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMediaPickerSharedControlsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val onActionClick = mockk<() -> Unit>(relaxed = true) + private val onButtonClick = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUpConversationMediaPickerSharedControlsTest() { + clearAllMocks() + } + + @Test + fun permissionFallback_rendersMessageActionAndForwardsClick() { + setPermissionFallbackContent() + + composeTestRule.onNodeWithText(PERMISSION_MESSAGE).assertIsDisplayed() + composeTestRule.onNodeWithText(PERMISSION_ACTION).assertIsDisplayed() + + composeTestRule + .onNodeWithText(PERMISSION_ACTION) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onActionClick.invoke() + } + } + } + + @Test + fun overlayBackgroundButton_forwardsClick() { + setBackgroundButtonContent() + + composeTestRule + .onNodeWithContentDescription(BACKGROUND_BUTTON_DESCRIPTION) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onButtonClick.invoke() + } + } + } + + @Test + fun overlayIconButton_enabledForwardsClick() { + setIconButtonContent(enabled = true) + + composeTestRule + .onNodeWithContentDescription(ICON_BUTTON_DESCRIPTION) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onButtonClick.invoke() + } + } + } + + @Test + fun overlayIconButton_disabledIgnoresClick() { + setIconButtonContent(enabled = false) + + composeTestRule + .onNodeWithContentDescription(ICON_BUTTON_DESCRIPTION) + .assertIsNotEnabled() + .performTouchInput { + click(position = center) + } + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onButtonClick.invoke() + } + } + } + + private fun setPermissionFallbackContent() { + composeTestRule.setContent { + AppTheme { + PermissionFallback( + icon = { + Icon( + imageVector = Icons.Rounded.CameraAlt, + contentDescription = null, + ) + }, + message = PERMISSION_MESSAGE, + actionLabel = PERMISSION_ACTION, + onActionClick = onActionClick, + ) + } + } + } + + private fun setBackgroundButtonContent() { + composeTestRule.setContent { + AppTheme { + PickerOverlayBackgroundButton( + contentDescription = BACKGROUND_BUTTON_DESCRIPTION, + imageVector = Icons.Rounded.Close, + onClick = onButtonClick, + ) + } + } + } + + private fun setIconButtonContent(enabled: Boolean) { + composeTestRule.setContent { + AppTheme { + PickerOverlayIconButton( + contentDescription = ICON_BUTTON_DESCRIPTION, + enabled = enabled, + imageVector = Icons.Rounded.Close, + onClick = onButtonClick, + ) + } + } + } + + private companion object { + private const val PERMISSION_MESSAGE = "Camera access is required" + private const val PERMISSION_ACTION = "Allow camera" + private const val BACKGROUND_BUTTON_DESCRIPTION = "Background button" + private const val ICON_BUTTON_DESCRIPTION = "Icon button" + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index bba7447b7..a48f010eb 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -23,6 +23,8 @@ internal const val CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG = "conversation_delete_conversation_button" internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" +internal const val CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG = + "conversation_media_capture_shutter_button" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" internal const val CONVERSATION_MMS_INDICATOR_TEST_TAG = "conversation_mms_indicator" internal const val CONVERSATION_SEGMENT_COUNTER_TEST_TAG = "conversation_segment_counter" @@ -103,6 +105,10 @@ internal fun conversationAttachmentPreviewRemoveButtonTestTag( return "conversation_attachment_preview_remove_button_$attachmentKey" } +internal fun conversationMediaReviewPreviewTestTag(contentUri: String): String { + return "conversation_media_review_preview_$contentUri" +} + internal fun newChatContactRowTestTag(contactId: String): String { return "new_chat_contact_row_$contactId" } diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt index 5626aad04..95bc9932b 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt @@ -24,10 +24,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode @@ -115,6 +117,8 @@ internal fun ConversationMediaCaptureControls( } ConversationMediaCaptureShutterButton( + modifier = Modifier + .testTag(CONVERSATION_MEDIA_CAPTURE_SHUTTER_BUTTON_TEST_TAG), captureMode = captureMode, isPhotoCaptureInProgress = isPhotoCaptureInProgress, isRecording = isRecording, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt index c0ed3d26e..59cca14da 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -51,6 +51,7 @@ private val PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC = spring( @Composable internal fun ConversationMediaCaptureShutterButton( + modifier: Modifier = Modifier, captureMode: ConversationCaptureMode, isPhotoCaptureInProgress: Boolean, isRecording: Boolean, @@ -63,6 +64,7 @@ internal fun ConversationMediaCaptureShutterButton( isRecording = isRecording, ) ConversationMediaCaptureShutterButtonAnimatedContent( + modifier = modifier, colorScheme = colorScheme, isEnabled = isEnabled, onClick = onClick, @@ -72,6 +74,7 @@ internal fun ConversationMediaCaptureShutterButton( @Composable private fun ConversationMediaCaptureShutterButtonAnimatedContent( + modifier: Modifier, colorScheme: ColorScheme, isEnabled: Boolean, onClick: () -> Unit, @@ -83,6 +86,7 @@ private fun ConversationMediaCaptureShutterButtonAnimatedContent( ) ConversationMediaCaptureShutterButtonShell( + modifier = modifier, borderColor = pickerOverlayContentColor(), isEnabled = isEnabled, onClick = onClick, @@ -255,6 +259,7 @@ private fun Transition.animateVideoCenterD @Composable private fun ConversationMediaCaptureShutterButtonShell( + modifier: Modifier, borderColor: Color, isEnabled: Boolean, onClick: () -> Unit, @@ -263,7 +268,7 @@ private fun ConversationMediaCaptureShutterButtonShell( content: @Composable () -> Unit, ) { Surface( - modifier = Modifier + modifier = modifier .size(PICKER_SHUTTER_OUTER_SIZE) .graphicsLayer { alpha = if (isEnabled) 1f else 0.7f diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt index 6c1c32698..f75982f63 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp @@ -42,6 +43,7 @@ import androidx.compose.ui.zIndex import com.android.messaging.R import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.conversationMediaReviewPreviewTestTag import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayBackgroundButton import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor @@ -53,7 +55,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay -private const val PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS = 160 +internal const val PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS = 160 @Composable internal fun ConversationMediaReviewPageCard( @@ -194,6 +196,11 @@ private fun ConversationMediaReviewPageCardContent( ConversationMediaReviewPreview( modifier = Modifier .fillMaxSize() + .testTag( + tag = conversationMediaReviewPreviewTestTag( + contentUri = attachment.contentUri, + ), + ) .clickable( enabled = contentState.isPreviewEnabled, onClick = { onAttachmentPreviewClick(attachment) }, From 267258106bd1c33cf26bccfea1f1b908a2f0004e Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:15:12 +0300 Subject: [PATCH 20/38] Cover conversation message rendering --- ...onversationInlineAudioAttachmentRowTest.kt | 94 ++++++ ...onversationVCardInlineAttachmentRowTest.kt | 109 +++++++ ...versationGenericInlineAttachmentRowTest.kt | 106 ++++++ ...ionInlineAttachmentRowAudioPlaybackTest.kt | 58 ++++ ...versationInlineAttachmentRowRoutingTest.kt | 187 +++++++++++ ...nInlineAudioAttachmentPlaybackStateTest.kt | 252 +++++++++++++++ ...ersationMessageAttachmentsRenderingTest.kt | 152 +++++++++ ...rsationVisualAttachmentsInteractionTest.kt | 167 ++++++++++ .../ConversationMessagesListRenderingTest.kt | 303 ++++++++++++++++++ .../ConversationMessageAvatarRenderingTest.kt | 119 +++++++ ...onversationMessageBubbleInteractionTest.kt | 245 ++++++++++++++ .../ConversationMessageBubbleRenderingTest.kt | 148 +++++++++ ...onversationMmsDownloadBodyRenderingTest.kt | 158 +++++++++ 13 files changed, 2098 insertions(+) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRowTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationGenericInlineAttachmentRowTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowAudioPlaybackTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowRoutingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentsRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/list/ConversationMessagesListRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageAvatarRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMmsDownloadBodyRenderingTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowTest.kt new file mode 100644 index 000000000..f615cfaf6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowTest.kt @@ -0,0 +1,94 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationInlineAudioAttachmentRowTest : + BaseConversationInlineAudioAttachmentRowTest() { + + @Test + fun idleState_hidesProgressIndicator() { + setContent( + isPlaying = false, + progress = 0f, + ) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun playingState_showsProgressIndicatorWithoutProgress() { + setContent( + isPlaying = true, + progress = 0f, + ) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG) + .assertCountEquals(expectedSize = 1) + } + + @Test + fun progressState_showsProgressIndicatorWhenNotPlaying() { + setContent( + isPlaying = false, + progress = 0.45f, + ) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG) + .assertCountEquals(expectedSize = 1) + } + + @Test + fun playButton_clickTogglesContentDescription() { + var isPlaying by mutableStateOf(value = false) + var clicks = 0 + val playLabel = targetContext.getString(R.string.audio_play_content_description) + val pauseLabel = targetContext.getString(R.string.audio_pause_content_description) + + setContent( + isPlaying = { isPlaying }, + progress = { 0f }, + onClick = { + clicks += 1 + isPlaying = !isPlaying + }, + ) + + composeTestRule + .onAllNodesWithContentDescription(playLabel) + .assertCountEquals(expectedSize = 1) + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, clicks) + } + + composeTestRule + .onAllNodesWithContentDescription(pauseLabel) + .assertCountEquals(expectedSize = 1) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRowTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRowTest.kt new file mode 100644 index 000000000..938d9be9b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRowTest.kt @@ -0,0 +1,109 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.core.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationVCardInlineAttachmentRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun loadedContactUiModel_rendersDisplayNameAndDetails() { + setContent( + attachment = ConversationInlineAttachment.VCard( + key = "attachment-1", + contentUri = "content://mms/part/vcard-1", + openAction = null, + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + titleText = "Sam Rivera", + titleTextResId = null, + subtitleText = "sam@example.com", + subtitleTextResId = null, + ), + ) + + composeTestRule + .onNodeWithText("Sam Rivera") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("sam@example.com") + .assertIsDisplayed() + } + + @Test + fun loadedLocationUiModel_withoutName_usesLocationFallbackTitle() { + setContent( + attachment = ConversationInlineAttachment.VCard( + key = "attachment-1", + contentUri = "content://mms/part/vcard-1", + openAction = null, + type = ConversationVCardAttachmentType.LOCATION, + avatarUri = null, + titleText = null, + titleTextResId = R.string.notification_location, + subtitleText = "25 11th Ave New York NY 10011 United States", + subtitleTextResId = null, + ), + ) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.notification_location)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("25 11th Ave New York NY 10011 United States") + .assertIsDisplayed() + } + + @Test + fun missingUiModelDetails_rendersDefaultStringsFromResources() { + setContent( + attachment = ConversationInlineAttachment.VCard( + key = "attachment-1", + contentUri = "content://mms/part/vcard-1", + openAction = null, + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = null, + titleText = null, + titleTextResId = R.string.notification_vcard, + subtitleText = null, + subtitleTextResId = R.string.vcard_tap_hint, + ), + ) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.notification_vcard)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(targetContext.getString(R.string.vcard_tap_hint)) + .assertIsDisplayed() + } + + private fun setContent( + attachment: ConversationInlineAttachment.VCard, + ) { + composeTestRule.setContent { + AppTheme { + ConversationVCardInlineAttachmentRow( + attachment = attachment, + isSelectionMode = false, + onAttachmentClick = { _, _ -> }, + onExternalUriClick = {}, + onLongClick = {}, + ) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationGenericInlineAttachmentRowTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationGenericInlineAttachmentRowTest.kt new file mode 100644 index 000000000..67b7594ab --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationGenericInlineAttachmentRowTest.kt @@ -0,0 +1,106 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationGenericInlineAttachmentRowTest : + BaseConversationMessageAttachmentRenderingTest() { + + @Test + fun explicitTitle_rendersTitleText() { + setGenericInlineAttachmentContent( + attachment = fileInlineAttachment(titleText = "Quarterly report.pdf"), + ) + + composeTestRule + .onNodeWithText("Quarterly report.pdf") + .assertIsDisplayed() + } + + @Test + fun fallbackTitleAndSubtitleResources_renderLocalizedText() { + setGenericInlineAttachmentContent( + attachment = fileInlineAttachment( + subtitleTextResId = R.string.copy_to_clipboard, + titleText = null, + titleTextResId = R.string.notification_file, + ), + ) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.notification_file)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(targetContext.getString(R.string.copy_to_clipboard)) + .assertIsDisplayed() + } + + @Test + fun openExternalAction_forwardsExternalUri() { + setGenericInlineAttachmentContent( + attachment = fileInlineAttachment( + openAction = ConversationAttachmentOpenAction.OpenExternal( + uri = YOUTUBE_SOURCE_URI, + ), + ), + ) + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onExternalUriClick.invoke(YOUTUBE_SOURCE_URI) + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + } + } + + @Test + fun nullOpenAction_clickDoesNotDispatchOpenCallbacks() { + setGenericInlineAttachmentContent( + attachment = fileInlineAttachment(openAction = null), + ) + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun longClickForwardsCallbackWithoutOpeningAttachment() { + setGenericInlineAttachmentContent( + attachment = fileInlineAttachment(), + ) + + longClickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke() + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowAudioPlaybackTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowAudioPlaybackTest.kt new file mode 100644 index 000000000..170275f26 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowAudioPlaybackTest.kt @@ -0,0 +1,58 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithContentDescription +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowMediaPlayer +import org.robolectric.shadows.util.DataSource + +private const val ROW_AUDIO_DURATION_MILLIS = 18_000 + +@RunWith(RobolectricTestRunner::class) +internal class ConversationInlineAttachmentRowAudioPlaybackTest : + BaseConversationMessageAttachmentRenderingTest() { + + private lateinit var shadowMediaPlayer: ShadowMediaPlayer + + @Before + fun setUpMediaPlayer() { + ShadowMediaPlayer.resetStaticState() + ShadowMediaPlayer.addMediaInfo( + DataSource.toDataSource(AUDIO_CONTENT_URI), + ShadowMediaPlayer.MediaInfo(ROW_AUDIO_DURATION_MILLIS, -1), + ) + ShadowMediaPlayer.setCreateListener { _, shadow -> + shadowMediaPlayer = shadow + shadow.setInvalidStateBehavior(ShadowMediaPlayer.InvalidStateBehavior.ASSERT) + } + } + + @After + fun tearDownMediaPlayer() { + ShadowMediaPlayer.resetStaticState() + } + + @Test + fun audioAttachmentRow_clickStartsPlaybackAndShowsPauseAction() { + val pauseLabel = targetContext.getString(R.string.audio_pause_content_description) + + setInlineAttachmentRowContent( + attachment = audioInlineAttachment(), + isSelectionMode = false, + ) + + clickInlineRow() + shadowMediaPlayer.invokePreparedListener() + composeTestRule.waitForIdle() + + composeTestRule + .onAllNodesWithContentDescription(label = pauseLabel) + .assertCountEquals(expectedSize = 1) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowRoutingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowRoutingTest.kt new file mode 100644 index 000000000..732770fb4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAttachmentRowRoutingTest.kt @@ -0,0 +1,187 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationInlineAttachmentRowRoutingTest : + BaseConversationMessageAttachmentRenderingTest() { + + @Test + fun audioAttachmentRow_rendersAudioTitle() { + setInlineAttachmentRowContent( + attachment = audioInlineAttachment(), + isSelectionMode = true, + ) + + composeTestRule + .onNodeWithText(AUDIO_TITLE) + .assertIsDisplayed() + } + + @Test + fun vCardAttachmentRow_rendersVCardDetailsAndOpensContent() { + setInlineAttachmentRowContent( + attachment = vCardInlineAttachment(), + ) + + composeTestRule + .onNodeWithText(VCARD_TITLE) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(VCARD_SUBTITLE) + .assertIsDisplayed() + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(VCARD_CONTENT_TYPE, VCARD_CONTENT_URI) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun vCardSelectionMode_clickAndLongClickDoNotDispatchCallbacks() { + setInlineAttachmentRowContent( + attachment = vCardInlineAttachment(), + isSelectionMode = true, + ) + + clickInlineRow() + longClickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + verify(exactly = 0) { + onMessageLongClick.invoke() + } + } + } + + @Test + fun vCardWithoutOpenAction_clickDoesNotDispatchOpenCallbacks() { + setInlineAttachmentRowContent( + attachment = vCardInlineAttachment(openAction = null), + ) + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun fileAttachmentRow_rendersFileTitleAndOpensContent() { + setInlineAttachmentRowContent( + attachment = fileInlineAttachment(), + ) + + composeTestRule + .onNodeWithText(FILE_TITLE) + .assertIsDisplayed() + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(FILE_CONTENT_TYPE, FILE_CONTENT_URI) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun audioContentSelectionMode_clickDoesNotStartPlayback() { + setAudioRowContent(isSelectionMode = true) + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onAudioRowClick.invoke() + } + } + } + + @Test + fun audioContentNonSelectionMode_clickAndLongClickForwardCallbacks() { + setAudioRowContent(isSelectionMode = false) + + clickInlineRow() + longClickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAudioRowClick.invoke() + } + verify(exactly = 1) { + onMessageLongClick.invoke() + } + } + } + + @Test + fun incomingAudioContentWithStandaloneBackground_remainsClickable() { + setAudioRowContent( + isSelectionMode = false, + isIncoming = true, + useStandaloneAudioAttachmentBackground = true, + ) + + composeTestRule + .onNodeWithText(AUDIO_TITLE) + .assertIsDisplayed() + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAudioRowClick.invoke() + } + } + } + + @Test + fun outgoingAudioContentWithStandaloneBackground_remainsClickable() { + setAudioRowContent( + isSelectionMode = false, + isIncoming = false, + useStandaloneAudioAttachmentBackground = true, + ) + + composeTestRule + .onNodeWithText(AUDIO_TITLE) + .assertIsDisplayed() + + clickInlineRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAudioRowClick.invoke() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt new file mode 100644 index 000000000..f22a9c2a0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt @@ -0,0 +1,252 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import android.media.MediaPlayer +import com.android.common.test.helpers.targetContext +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationInlineAudioAttachmentPlaybackState +import io.mockk.mockk +import io.mockk.verify +import java.io.IOException +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowMediaPlayer +import org.robolectric.shadows.util.DataSource + +private const val STATE_AUDIO_CONTENT_URI = "content://mms/part/test-audio" +private const val STATE_MISSING_AUDIO_CONTENT_URI = "content://mms/part/missing-audio" +private const val STATE_AUDIO_DURATION_MILLIS = 18_000 +private const val STATE_PAUSED_POSITION_MILLIS = 4_500 + +@RunWith(RobolectricTestRunner::class) +internal class ConversationInlineAudioAttachmentPlaybackStateTest { + + private val onPlaybackFailure = mockk<() -> Unit>(relaxed = true) + private lateinit var shadowMediaPlayer: ShadowMediaPlayer + + @Before + fun setUp() { + ShadowMediaPlayer.resetStaticState() + ShadowMediaPlayer.setCreateListener { _, shadow -> + shadowMediaPlayer = shadow + shadow.setInvalidStateBehavior(ShadowMediaPlayer.InvalidStateBehavior.ASSERT) + } + } + + @After + fun tearDown() { + ShadowMediaPlayer.resetStaticState() + } + + @Test + fun initialState_reportsZeroDurationAndProgress() { + val playbackState = playbackState() + + assertEquals(0L, playbackState.durationMillis) + assertEquals(0L, playbackState.positionMillis) + assertEquals(0f, playbackState.progress) + assertEquals("00:00", playbackState.durationLabel) + assertFalse(playbackState.isPlaying) + verify(exactly = 0) { + onPlaybackFailure.invoke() + } + } + + @Test + fun togglePlayback_beforePreparedQueuesStartUntilPrepared() { + addAudioMediaInfo(preparationDelayMillis = -1) + val playbackState = playbackState() + + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + + assertFalse(playbackState.isPlaying) + assertEquals(0L, playbackState.durationMillis) + assertEquals(ShadowMediaPlayer.State.PREPARING, shadowMediaPlayer.getState()) + + shadowMediaPlayer.invokePreparedListener() + + assertTrue(playbackState.isPlaying) + assertEquals(STATE_AUDIO_DURATION_MILLIS.toLong(), playbackState.durationMillis) + assertEquals(0L, playbackState.positionMillis) + assertEquals("00:18", playbackState.durationLabel) + assertEquals(ShadowMediaPlayer.State.STARTED, shadowMediaPlayer.getState()) + verify(exactly = 0) { + onPlaybackFailure.invoke() + } + } + + @Test + fun togglePlaybackTwiceBeforePrepared_cancelsQueuedStart() { + addAudioMediaInfo(preparationDelayMillis = -1) + val playbackState = playbackState() + + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + + shadowMediaPlayer.invokePreparedListener() + + assertFalse(playbackState.isPlaying) + assertEquals(STATE_AUDIO_DURATION_MILLIS.toLong(), playbackState.durationMillis) + assertEquals(ShadowMediaPlayer.State.PREPARED, shadowMediaPlayer.getState()) + verify(exactly = 0) { + onPlaybackFailure.invoke() + } + } + + @Test + fun preparedPlayback_togglesPauseAndResume() { + val playbackState = startedPlaybackState() + + shadowMediaPlayer.setCurrentPosition(STATE_PAUSED_POSITION_MILLIS) + shadowMediaPlayer.doStop() + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + + assertFalse(playbackState.isPlaying) + assertEquals(STATE_PAUSED_POSITION_MILLIS.toLong(), playbackState.positionMillis) + assertEquals(0.25f, playbackState.progress) + assertEquals(ShadowMediaPlayer.State.PAUSED, shadowMediaPlayer.getState()) + + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + + assertTrue(playbackState.isPlaying) + assertEquals(ShadowMediaPlayer.State.STARTED, shadowMediaPlayer.getState()) + verify(exactly = 0) { + onPlaybackFailure.invoke() + } + } + + @Test + fun playbackCompletion_resetsPositionAndCanRestart() { + val playbackState = startedPlaybackState() + + shadowMediaPlayer.invokeCompletionListener() + + assertFalse(playbackState.isPlaying) + assertEquals(0L, playbackState.positionMillis) + assertEquals(ShadowMediaPlayer.State.PLAYBACK_COMPLETED, shadowMediaPlayer.getState()) + + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + + assertTrue(playbackState.isPlaying) + assertEquals(0L, playbackState.positionMillis) + assertEquals(ShadowMediaPlayer.State.STARTED, shadowMediaPlayer.getState()) + verify(exactly = 0) { + onPlaybackFailure.invoke() + } + } + + @Test + fun updateProgress_readsCurrentPlaybackPosition() { + val playbackState = startedPlaybackState() + + shadowMediaPlayer.setCurrentPosition(STATE_PAUSED_POSITION_MILLIS) + shadowMediaPlayer.doStop() + playbackState.updateProgress() + + assertEquals(STATE_PAUSED_POSITION_MILLIS.toLong(), playbackState.positionMillis) + assertEquals(0.25f, playbackState.progress) + } + + @Test + fun release_clearsPlayingStateAndStopsReadingProgress() { + val playbackState = startedPlaybackState() + + playbackState.release() + playbackState.updateProgress() + + assertFalse(playbackState.isPlaying) + assertEquals(0L, playbackState.positionMillis) + assertEquals(ShadowMediaPlayer.State.END, shadowMediaPlayer.getState()) + verify(exactly = 0) { + onPlaybackFailure.invoke() + } + } + + @Test + fun mediaError_reportsFailureAndResetsPlaybackState() { + val playbackState = startedPlaybackState() + + shadowMediaPlayer.invokeErrorListener(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0) + + assertEquals(0L, playbackState.durationMillis) + assertEquals(0L, playbackState.positionMillis) + assertEquals(0f, playbackState.progress) + assertFalse(playbackState.isPlaying) + assertEquals(ShadowMediaPlayer.State.END, shadowMediaPlayer.getState()) + verify(exactly = 1) { + onPlaybackFailure.invoke() + } + } + + @Test + fun invalidUri_reportsFailureAndResetsPlaybackState() { + ShadowMediaPlayer.addException( + DataSource.toDataSource(STATE_MISSING_AUDIO_CONTENT_URI), + IOException("missing audio"), + ) + val playbackState = playbackState() + + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_MISSING_AUDIO_CONTENT_URI, + ) + + assertEquals(0L, playbackState.durationMillis) + assertEquals(0L, playbackState.positionMillis) + assertEquals(0f, playbackState.progress) + assertFalse(playbackState.isPlaying) + assertEquals(ShadowMediaPlayer.State.END, shadowMediaPlayer.getState()) + verify(exactly = 1) { + onPlaybackFailure.invoke() + } + } + + private fun playbackState(): ConversationInlineAudioAttachmentPlaybackState { + return ConversationInlineAudioAttachmentPlaybackState( + onPlaybackFailure = onPlaybackFailure, + ) + } + + private fun startedPlaybackState(): ConversationInlineAudioAttachmentPlaybackState { + addAudioMediaInfo(preparationDelayMillis = -1) + val playbackState = playbackState() + + playbackState.togglePlayback( + context = targetContext, + contentUri = STATE_AUDIO_CONTENT_URI, + ) + shadowMediaPlayer.invokePreparedListener() + + assertTrue(playbackState.isPlaying) + return playbackState + } + + private fun addAudioMediaInfo(preparationDelayMillis: Int) { + ShadowMediaPlayer.addMediaInfo( + DataSource.toDataSource(STATE_AUDIO_CONTENT_URI), + ShadowMediaPlayer.MediaInfo(STATE_AUDIO_DURATION_MILLIS, preparationDelayMillis), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentsRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentsRenderingTest.kt new file mode 100644 index 000000000..5d728f948 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationMessageAttachmentsRenderingTest.kt @@ -0,0 +1,152 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.ui.attachment.buildConversationAttachmentSections +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageAttachmentsRenderingTest : + BaseConversationMessageAttachmentRenderingTest() { + + @Test + fun emptySections_renderNoAttachmentContainer() { + setMessageAttachmentsContent(attachmentSections = emptySections()) + + composeTestRule + .onAllNodesWithTag(testTag = MESSAGE_ATTACHMENTS_TAG) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun builtMixedSections_renderGalleryAndTrailingRows() { + val sections = buildConversationAttachmentSections( + attachments = persistentListOf( + imageAttachment(), + audioAttachment(), + fileAttachment(), + vCardMediaAttachment(), + videoAttachment(), + ), + ) + + setMessageAttachmentsContent(attachmentSections = sections) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.audio_attachment_content_description)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(FILE_TITLE) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(VCARD_TITLE) + .assertIsDisplayed() + } + + @Test + fun singleImageGallery_clickForwardsContentOpen() { + setMessageAttachmentsContent( + attachmentSections = gallerySections(imageAttachment()), + ) + + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, IMAGE_CONTENT_URI) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun standaloneVideo_clickForwardsContentOpen() { + setMessageAttachmentsContent( + attachmentSections = trailingSections( + standaloneVisualItem(attachment = videoAttachment()), + ), + ) + + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(VIDEO_CONTENT_TYPE, VIDEO_CONTENT_URI) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun inlineFile_clickForwardsContentOpen() { + setMessageAttachmentsContent( + attachmentSections = trailingSections( + inlineItem(attachment = fileInlineAttachment()), + ), + ) + + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(FILE_CONTENT_TYPE, FILE_CONTENT_URI) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun inlineFile_longClickForwardsMessageLongClickOnly() { + setMessageAttachmentsContent( + attachmentSections = trailingSections( + inlineItem(attachment = fileInlineAttachment()), + ), + ) + + longClickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke() + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun inlineFile_fallbackTitleResourceRendersLocalizedLabel() { + setMessageAttachmentsContent( + attachmentSections = trailingSections( + inlineItem( + attachment = fileInlineAttachment( + titleText = null, + titleTextResId = R.string.notification_file, + ), + ), + ), + ) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.notification_file)) + .assertIsDisplayed() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt new file mode 100644 index 000000000..877a51443 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt @@ -0,0 +1,167 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment.rendering + +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationVisualAttachmentsInteractionTest : + BaseConversationMessageAttachmentRenderingTest() { + + @Test + fun twoImageGallery_clicksEachCellForwardMatchingContent() { + setMessageAttachmentsContent( + attachmentSections = gallerySections( + imageAttachment(), + imageAttachment( + key = SECOND_IMAGE_KEY, + contentUri = SECOND_IMAGE_CONTENT_URI, + ), + ), + ) + + clickMessageAttachmentsAt(xFraction = 0.25f) + clickMessageAttachmentsAt(xFraction = 0.75f) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, IMAGE_CONTENT_URI) + } + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, SECOND_IMAGE_CONTENT_URI) + } + } + } + + @Test + fun threeImageGallery_clicksOddTrailingCellAndIgnoresFiller() { + setMessageAttachmentsContent( + attachmentSections = gallerySections( + imageAttachment(), + imageAttachment( + key = SECOND_IMAGE_KEY, + contentUri = SECOND_IMAGE_CONTENT_URI, + ), + imageAttachment( + key = THIRD_IMAGE_KEY, + contentUri = THIRD_IMAGE_CONTENT_URI, + ), + ), + hasTextAboveVisualAttachments = true, + hasTextBelowVisualAttachments = true, + ) + + clickMessageAttachmentsAt(xFraction = 0.25f, yFraction = 0.25f) + clickMessageAttachmentsAt(xFraction = 0.75f, yFraction = 0.25f) + clickMessageAttachmentsAt(xFraction = 0.25f, yFraction = 0.75f) + clickMessageAttachmentsAt(xFraction = 0.75f, yFraction = 0.75f) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, IMAGE_CONTENT_URI) + } + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, SECOND_IMAGE_CONTENT_URI) + } + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, THIRD_IMAGE_CONTENT_URI) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun youTubePreview_clickForwardsExternalUri() { + setMessageAttachmentsContent( + attachmentSections = gallerySections(youTubeAttachment()), + ) + + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onExternalUriClick.invoke(YOUTUBE_SOURCE_URI) + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + } + } + + @Test + fun standaloneUnsupportedAttachmentWithContentUri_opensContent() { + setStandaloneVisualAttachmentContent( + attachment = unsupportedAttachment(), + hasTextAboveVisualAttachments = true, + hasTextBelowVisualAttachments = true, + ) + + clickStandaloneVisual() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(UNSUPPORTED_CONTENT_TYPE, UNSUPPORTED_CONTENT_URI) + } + } + } + + @Test + fun imageAttachmentWithMissingDimensions_usesDefaultSizingAndOpensContent() { + setMessageAttachmentsContent( + attachmentSections = gallerySections( + imageAttachment( + width = 0, + height = 0, + ), + ), + ) + + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, IMAGE_CONTENT_URI) + } + } + } + + @Test + fun videoAttachmentWithMissingDimensions_usesDefaultSizingAndOpensContent() { + setStandaloneVisualAttachmentContent( + attachment = videoAttachment( + width = 0, + height = 0, + ), + ) + + clickStandaloneVisual() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(VIDEO_CONTENT_TYPE, VIDEO_CONTENT_URI) + } + } + } + + @Test + fun standaloneVisual_longClickForwardsMessageLongClickOnly() { + setStandaloneVisualAttachmentContent(attachment = videoAttachment()) + + longClickStandaloneVisual() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke() + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/list/ConversationMessagesListRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/list/ConversationMessagesListRenderingTest.kt new file mode 100644 index 000000000..2e6e22957 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/list/ConversationMessagesListRenderingTest.kt @@ -0,0 +1,303 @@ +package com.android.messaging.ui.conversation.messages.ui.list + +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag +import com.android.messaging.ui.conversation.conversationMessageItemTestTag +import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.ui.ConversationMessages +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessagesListRenderingTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val onAttachmentClick = mockk<(String, String) -> Unit>(relaxed = true) + private val onExternalUriClick = mockk<(String) -> Unit>(relaxed = true) + private val onMessageAvatarClick = mockk<(String) -> Unit>(relaxed = true) + private val onMessageClick = mockk<(String) -> Unit>(relaxed = true) + private val onMessageDownloadClick = mockk<(String) -> Unit>(relaxed = true) + private val onMessageLongClick = mockk<(String) -> Unit>(relaxed = true) + private val onMessageResendClick = mockk<(String) -> Unit>(relaxed = true) + private val onSimSelectorClick = mockk<() -> Unit>(relaxed = true) + + @Before + fun setUp() { + clearAllMocks() + } + + @Test + fun emptyList_rendersScrollableMessageContainerWithoutRows() { + setMessagesContent(messages = persistentListOf()) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_MESSAGES_LIST_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(testTag = conversationMessageItemTestTag(messageId = FIRST_MESSAGE_ID)) + .assertDoesNotExist() + } + + @Test + fun presentMessages_renderRowsAndForwardLongClickWithMessageId() { + setMessagesContent( + messages = persistentListOf( + message( + messageId = FIRST_MESSAGE_ID, + text = FIRST_MESSAGE_TEXT, + ), + message( + messageId = SECOND_MESSAGE_ID, + text = SECOND_MESSAGE_TEXT, + ), + ), + ) + + composeTestRule + .onNodeWithText(text = FIRST_MESSAGE_TEXT) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = SECOND_MESSAGE_TEXT) + .assertIsDisplayed() + + longClickBubble(messageId = SECOND_MESSAGE_ID) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke(SECOND_MESSAGE_ID) + } + verify(exactly = 0) { + onMessageClick.invoke(any()) + } + } + } + + @Test + fun selectionModeSelectedRow_clickAndLongClickForwardMessageId() { + setMessagesContent( + messages = persistentListOf( + message( + messageId = FIRST_MESSAGE_ID, + text = FIRST_MESSAGE_TEXT, + ), + ), + selectedMessageIds = persistentSetOf(FIRST_MESSAGE_ID), + ) + + composeTestRule + .onNodeWithTag( + testTag = conversationMessageSelectionRowTestTag( + messageId = FIRST_MESSAGE_ID, + ), + ) + .assertIsSelected() + .performClick() + + composeTestRule + .onNodeWithTag( + testTag = conversationMessageSelectionRowTestTag( + messageId = FIRST_MESSAGE_ID, + ), + ) + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageClick.invoke(FIRST_MESSAGE_ID) + } + verify(exactly = 1) { + onMessageLongClick.invoke(FIRST_MESSAGE_ID) + } + } + } + + @Test + fun sendSimIndicator_enabledForwardsClick() { + val annotationText = targetContext.getString( + R.string.conversation_send_sim_annotation, + SEND_SIM_DISPLAY_NAME, + ) + + setMessagesContent( + messages = persistentListOf(), + currentSendSimDisplayName = SEND_SIM_DISPLAY_NAME, + ) + + clickSendSimAnnotation(annotationText = annotationText) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onSimSelectorClick.invoke() + } + } + } + + @Test + fun sendSimIndicator_selectionModeDisablesClick() { + val annotationText = targetContext.getString( + R.string.conversation_send_sim_annotation, + SEND_SIM_DISPLAY_NAME, + ) + + setMessagesContent( + messages = persistentListOf( + message( + messageId = FIRST_MESSAGE_ID, + text = FIRST_MESSAGE_TEXT, + ), + ), + selectedMessageIds = persistentSetOf(FIRST_MESSAGE_ID), + currentSendSimDisplayName = SEND_SIM_DISPLAY_NAME, + ) + + clickSendSimAnnotation(annotationText = annotationText) + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onSimSelectorClick.invoke() + } + } + } + + private fun setMessagesContent( + messages: ImmutableList, + selectedMessageIds: ImmutableSet = persistentSetOf(), + currentSendSimDisplayName: String? = null, + ) { + composeTestRule.setContent { + AppTheme { + val listState = rememberLazyListState() + + ConversationMessages( + messages = messages, + listState = listState, + selectedMessageIds = selectedMessageIds, + subscriptions = persistentListOf(sendSubscription()), + currentSendSimDisplayName = currentSendSimDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, + ) + } + } + } + + private fun clickSendSimAnnotation(annotationText: String) { + composeTestRule + .onNodeWithText(text = annotationText) + .performTouchInput { + click( + position = Offset( + x = width * SEND_SIM_LINK_X_FRACTION, + y = height * SEND_SIM_LINK_Y_FRACTION, + ), + ) + } + } + + @Suppress("SameParameterValue") + private fun longClickBubble(messageId: String) { + composeTestRule + .onNodeWithTag( + testTag = conversationMessageBubbleTestTag(messageId = messageId), + ) + .performSemanticsAction(SemanticsActions.OnLongClick) + } + + private fun sendSubscription(): Subscription { + return Subscription( + selfParticipantId = SELF_PARTICIPANT_ID, + subId = SEND_SUBSCRIPTION_ID, + label = ConversationSubscriptionLabel.Named(name = SEND_SIM_DISPLAY_NAME), + displayDestination = null, + displaySlotId = SEND_SUBSCRIPTION_SLOT, + color = SEND_SUBSCRIPTION_COLOR, + ) + } + + private fun message( + messageId: String, + text: String, + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = messageId, + conversationId = CONVERSATION_ID, + text = text, + parts = persistentListOf(), + sentTimestamp = TIMESTAMP, + receivedTimestamp = TIMESTAMP, + displayTimestamp = TIMESTAMP, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = SELF_PARTICIPANT_ID, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) + } + + private companion object { + private const val FIRST_MESSAGE_ID = "message-1" + private const val FIRST_MESSAGE_TEXT = "First visible message" + private const val SECOND_MESSAGE_ID = "message-2" + private const val SECOND_MESSAGE_TEXT = "Second visible message" + private const val SELF_PARTICIPANT_ID = "self-1" + private const val SEND_SIM_DISPLAY_NAME = "Work SIM" + private const val SEND_SIM_LINK_X_FRACTION = 0.8f + private const val SEND_SIM_LINK_Y_FRACTION = 0.5f + private const val SEND_SUBSCRIPTION_COLOR = 0 + private const val SEND_SUBSCRIPTION_ID = 1 + private const val SEND_SUBSCRIPTION_SLOT = 0 + private const val TIMESTAMP = 1_700_000_000_000L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageAvatarRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageAvatarRenderingTest.kt new file mode 100644 index 000000000..d3f812605 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageAvatarRenderingTest.kt @@ -0,0 +1,119 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import android.net.Uri +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageAvatarRenderingTest : BaseConversationMessageRenderingTest() { + + @Test + fun nameDisplayName_rendersUppercaseInitial() { + setAvatarContent( + message = message( + isIncoming = true, + senderDisplayName = " alex ", + ), + ) + + composeTestRule + .onNodeWithText(text = "A") + .assertIsDisplayed() + } + + @Test + fun nullDisplayName_keepsClickableFallbackWithoutInitial() { + setAvatarContent( + message = message( + isIncoming = true, + senderDisplayName = null, + ), + ) + + composeTestRule + .onNodeWithTag(testTag = AVATAR_TAG) + .assertIsDisplayed() + clickAvatar() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAvatarClick.invoke() + } + } + } + + @Test + fun blankDisplayName_omitsInitial() { + setAvatarContent( + message = message( + isIncoming = true, + senderDisplayName = " ", + ), + ) + + composeTestRule + .onNodeWithTag(testTag = AVATAR_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = " ") + .assertDoesNotExist() + } + + @Test + fun phoneNumberDisplayName_omitsInitial() { + setAvatarContent( + message = message( + isIncoming = true, + senderDisplayName = "+1 650 555 0101", + ), + ) + + composeTestRule + .onNodeWithTag(testTag = AVATAR_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "+") + .assertDoesNotExist() + } + + @Test + fun avatarUri_keepsFallbackInitialWhileImageLoads() { + setAvatarContent( + message = message( + isIncoming = true, + senderDisplayName = "Morgan", + senderAvatarUri = Uri.parse("content://contacts/avatar/morgan"), + ), + ) + + composeTestRule + .onNodeWithText(text = "M") + .assertIsDisplayed() + } + + @Test + fun longClick_forwardsMessageLongClick() { + setAvatarContent( + message = message( + isIncoming = true, + senderDisplayName = "Riley", + ), + ) + + longClickAvatar() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke() + } + verify(exactly = 0) { + onAvatarClick.invoke() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt new file mode 100644 index 000000000..51d420605 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt @@ -0,0 +1,245 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageBubbleInteractionTest : + BaseConversationMessageRenderingTest() { + + @Test + fun downloadableMmsMessage_clickForwardsDownloadOnly() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Incoming.YetToManualDownload, + isIncoming = true, + canDownloadMessage = true, + mmsDownload = mmsDownload( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + ), + protocol = ConversationMessageUiModel.Protocol.MMS_PUSH_NOTIFICATION, + ), + ) + + clickBubble() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onDownloadClick.invoke() + } + verify(exactly = 0) { + onResendClick.invoke() + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + } + } + + @Test + fun downloadBlockedMmsMessage_clickIsNoOp() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Incoming.YetToManualDownload, + isIncoming = true, + canDownloadMessage = false, + mmsDownload = mmsDownload( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + ), + protocol = ConversationMessageUiModel.Protocol.MMS_PUSH_NOTIFICATION, + ), + ) + + clickBubble() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onDownloadClick.invoke() + } + verify(exactly = 0) { + onResendClick.invoke() + } + verify(exactly = 0) { + onMessageClick.invoke() + } + } + } + + @Test + fun resendableTextMessage_clickForwardsResendOnly() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Outgoing.Failed, + canResendMessage = true, + ), + ) + + clickBubble() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onResendClick.invoke() + } + verify(exactly = 0) { + onDownloadClick.invoke() + } + verify(exactly = 0) { + onMessageClick.invoke() + } + } + } + + @Test + fun visualAttachmentClick_normalModeForwardsAttachmentOpen() { + setConversationMessageContent( + message = message( + text = null, + parts = persistentListOf( + imagePart(), + ), + protocol = ConversationMessageUiModel.Protocol.MMS, + ), + showIncomingParticipantIdentity = false, + ) + + clickBubble() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, IMAGE_CONTENT_URI) + } + verify(exactly = 0) { + onMessageClick.invoke() + } + } + } + + @Test + fun visualAttachmentClick_selectionModeForwardsMessageClick() { + setConversationMessageContent( + message = message( + text = null, + parts = persistentListOf( + imagePart(), + ), + protocol = ConversationMessageUiModel.Protocol.MMS, + ), + isSelected = true, + isSelectionMode = true, + showIncomingParticipantIdentity = false, + ) + + composeTestRule + .onNodeWithTag( + testTag = conversationMessageSelectionRowTestTag( + messageId = DEFAULT_MESSAGE_ID, + ), + ) + .assertIsSelected() + + clickSelectionRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageClick.invoke() + } + verify(exactly = 0) { + onAttachmentClick.invoke(any(), any()) + } + } + } + + @Test + fun selectedDownloadMessage_selectionModeClickForwardsMessageClickOnly() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Incoming.DownloadFailed, + isIncoming = true, + canDownloadMessage = true, + mmsDownload = mmsDownload( + state = MmsDownloadUiModel.State.DownloadFailed, + ), + protocol = ConversationMessageUiModel.Protocol.MMS_PUSH_NOTIFICATION, + ), + isSelected = true, + isSelectionMode = true, + ) + + composeTestRule + .onNodeWithTag( + testTag = conversationMessageSelectionRowTestTag( + messageId = DEFAULT_MESSAGE_ID, + ), + ) + .assertIsSelected() + + clickSelectionRow() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageClick.invoke() + } + verify(exactly = 0) { + onDownloadClick.invoke() + } + verify(exactly = 0) { + onResendClick.invoke() + } + } + } + + @Test + fun bubbleLongClick_forwardsMessageLongClickOnly() { + setConversationMessageContent( + message = message(), + ) + + longClickBubble() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke() + } + verify(exactly = 0) { + onMessageClick.invoke() + } + verify(exactly = 0) { + onDownloadClick.invoke() + } + verify(exactly = 0) { + onResendClick.invoke() + } + } + } + + @Test + fun incomingAvatarClick_forwardsAvatarClick() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = "Nora", + ), + ) + + composeTestRule + .onNodeWithText(text = "N") + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAvatarClick.invoke() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleRenderingTest.kt new file mode 100644 index 000000000..81e31a50b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleRenderingTest.kt @@ -0,0 +1,148 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageBubbleRenderingTest : BaseConversationMessageRenderingTest() { + + @Test + fun incomingTextMessage_showsSenderBodyAndSimMetadata() { + val simAnnotation = targetContext.getString( + R.string.conversation_message_sim_annotation, + SIM_DISPLAY_NAME, + ) + + setConversationMessageContent( + message = message( + text = INCOMING_BODY_TEXT, + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = SENDER_DISPLAY_NAME, + ), + simDisplayName = SIM_DISPLAY_NAME, + ) + + composeTestRule + .onNodeWithText(text = SENDER_DISPLAY_NAME) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = INCOMING_BODY_TEXT) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = simAnnotation, substring = true) + .assertIsDisplayed() + } + + @Test + fun outgoingTextMessage_hidesIncomingIdentity() { + setConversationMessageContent( + message = message( + text = OUTGOING_BODY_TEXT, + isIncoming = false, + senderDisplayName = SENDER_DISPLAY_NAME, + ), + ) + + composeTestRule + .onNodeWithText(text = OUTGOING_BODY_TEXT) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = SENDER_DISPLAY_NAME) + .assertDoesNotExist() + } + + @Test + fun mmsDownloadMessage_rendersDownloadBodyInsteadOfRegularBody() { + setConversationMessageContent( + message = message( + text = HIDDEN_MMS_TEXT, + status = ConversationMessageUiModel.Status.Incoming.YetToManualDownload, + isIncoming = true, + canDownloadMessage = true, + mmsDownload = mmsDownload( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + ), + protocol = ConversationMessageUiModel.Protocol.MMS_PUSH_NOTIFICATION, + ), + ) + + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.message_title_manual_download)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = targetContext.getString(R.string.message_status_download)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = HIDDEN_MMS_TEXT) + .assertDoesNotExist() + } + + @Test + fun attachmentWithSenderSubjectAndBody_rendersHeaderMediaAndFooter() { + setConversationMessageContent( + message = message( + text = ATTACHMENT_BODY_TEXT, + parts = persistentListOf( + imagePart(), + ), + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = SENDER_DISPLAY_NAME, + mmsSubject = MMS_SUBJECT, + protocol = ConversationMessageUiModel.Protocol.MMS, + ), + ) + + composeTestRule + .onNodeWithText(text = SENDER_DISPLAY_NAME) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = MMS_SUBJECT) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = ATTACHMENT_BODY_TEXT) + .assertIsDisplayed() + } + + @Test + fun clusteredIncomingMessage_hidesSenderAndAvatarInitial() { + setConversationMessageContent( + message = message( + text = INCOMING_BODY_TEXT, + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = "Zoe", + canClusterWithPrevious = true, + canClusterWithNext = true, + ), + ) + + composeTestRule + .onNodeWithText(text = INCOMING_BODY_TEXT) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "Zoe") + .assertDoesNotExist() + composeTestRule + .onNodeWithText(text = "Z") + .assertDoesNotExist() + } + + private companion object { + private const val ATTACHMENT_BODY_TEXT = "Photo caption body" + private const val HIDDEN_MMS_TEXT = "This text should not render for MMS notification" + private const val INCOMING_BODY_TEXT = "Incoming message body" + private const val MMS_SUBJECT = "Trip photos" + private const val OUTGOING_BODY_TEXT = "Outgoing message body" + private const val SENDER_DISPLAY_NAME = "Alice Rivera" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMmsDownloadBodyRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMmsDownloadBodyRenderingTest.kt new file mode 100644 index 000000000..7ee45e360 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMmsDownloadBodyRenderingTest.kt @@ -0,0 +1,158 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMmsDownloadBodyRenderingTest : + BaseConversationMessageRenderingTest() { + + @Test + fun awaitingManualDownload_rendersDownloadTitleInfoAndAction() { + val download = mmsDownload( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + ) + + setMmsDownloadBodyContent( + download = download, + canDownloadMessage = true, + ) + + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_title_manual_download)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = expectedInfoText(download = download)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_status_download)) + .assertIsDisplayed() + } + + @Test + fun downloading_rendersInProgressStatus() { + setMmsDownloadBodyContent( + download = mmsDownload(state = MmsDownloadUiModel.State.Downloading), + canDownloadMessage = false, + ) + + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_title_downloading)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_status_downloading)) + .assertIsDisplayed() + } + + @Test + fun downloadFailed_rendersRetryAction() { + setMmsDownloadBodyContent( + download = mmsDownload(state = MmsDownloadUiModel.State.DownloadFailed), + canDownloadMessage = true, + ) + + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_title_download_failed)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_status_download)) + .assertIsDisplayed() + } + + @Test + fun expiredOrUnavailable_rendersUnavailableStatus() { + setMmsDownloadBodyContent( + download = mmsDownload(state = MmsDownloadUiModel.State.ExpiredOrUnavailable), + canDownloadMessage = false, + ) + + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_title_download_failed)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = stringResourceText(R.string.message_status_download_error)) + .assertIsDisplayed() + } + + @Test + fun simDisplayName_presentAppendsSimAnnotation() { + val statusLine = buildStatusLineText( + statusText = stringResourceText(R.string.message_status_download), + simDisplayName = SIM_DISPLAY_NAME, + ) + + setMmsDownloadBodyContent( + download = mmsDownload(state = MmsDownloadUiModel.State.AwaitingManualDownload), + canDownloadMessage = true, + simDisplayName = SIM_DISPLAY_NAME, + ) + + composeTestRule + .onNodeWithText(text = statusLine) + .assertIsDisplayed() + } + + @Test + fun simDisplayName_blankOmitsSimAnnotation() { + val statusText = stringResourceText(R.string.message_status_download) + + setMmsDownloadBodyContent( + download = mmsDownload(state = MmsDownloadUiModel.State.AwaitingManualDownload), + canDownloadMessage = true, + simDisplayName = " ", + ) + + composeTestRule + .onNodeWithText(text = statusText) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + text = stringResourceText( + resourceId = R.string.conversation_message_sim_annotation, + argument = SIM_DISPLAY_NAME, + ), + substring = true, + ) + .assertDoesNotExist() + } + + private fun expectedInfoText(download: MmsDownloadUiModel): String { + val formattedSize = Formatter.formatFileSize(targetContext, download.sizeBytes) + val formattedExpiry = DateUtils.formatDateTime( + targetContext, + download.expiryTimestamp, + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_SHOW_TIME or + DateUtils.FORMAT_NUMERIC_DATE or + DateUtils.FORMAT_NO_YEAR, + ) + + return targetContext.getString(R.string.mms_info, formattedSize, formattedExpiry) + } + + @Suppress("SameParameterValue") + private fun buildStatusLineText(statusText: String, simDisplayName: String): String { + val simAnnotation = stringResourceText( + resourceId = R.string.conversation_message_sim_annotation, + argument = simDisplayName, + ) + + return "$statusText \u2022 $simAnnotation" + } + + private fun stringResourceText(resourceId: Int): String { + return targetContext.getString(resourceId) + } + + private fun stringResourceText(resourceId: Int, argument: Any): String { + return targetContext.getString(resourceId, argument) + } +} From ffc364db1fff01d3ffd9bcded797ab0c20a5c2d5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:15:29 +0300 Subject: [PATCH 21/38] Cover recipient picker UI --- .../RecipientPickerScreenTest.kt | 46 ++++ .../RecipientSelectionContentTest.kt | 217 +++++++++++++++++ .../RecipientSelectionQueryCardTest.kt | 36 +-- .../RecipientSelectionQueryFieldTest.kt | 63 +++-- ...ientSelectionSelectedRecipientChipsTest.kt | 199 ++++++++++++++++ ...ltiDestinationContactRowInteractionTest.kt | 93 ++++++++ ...MultiDestinationContactRowRenderingTest.kt | 160 +++++++++++++ ...RecipientSelectionContactRowRoutingTest.kt | 178 ++++++++++++++ .../RecipientSelectionContactRowShapeTest.kt | 48 ++++ ...entSelectionContentMultiDestinationTest.kt | 98 ++++++++ .../simselector/NewChatSimSelectorRowTest.kt | 224 ++++++++++++++++++ 11 files changed, 1329 insertions(+), 33 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreenTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentTest.kt rename app/src/{androidTest/java => test/kotlin}/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt (87%) rename app/src/{androidTest/java => test/kotlin}/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt (73%) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionSelectedRecipientChipsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowInteractionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowRoutingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowShapeTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContentMultiDestinationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelectorRowTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreenTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreenTest.kt new file mode 100644 index 000000000..c01436a4f --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreenTest.kt @@ -0,0 +1,46 @@ +package com.android.messaging.ui.conversation.recipientpicker + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.navigation.RecipientPickerMode +import com.android.messaging.ui.core.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RecipientPickerScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun screen_createGroupMode_showsCreateGroupTitle() { + setContent(mode = RecipientPickerMode.CREATE_GROUP) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.conversation_new_group)) + .assertIsDisplayed() + } + + @Test + fun screen_addParticipantsMode_showsAddPeopleTitle() { + setContent(mode = RecipientPickerMode.ADD_PARTICIPANTS) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.conversation_add_people)) + .assertIsDisplayed() + } + + private fun setContent(mode: RecipientPickerMode) { + composeTestRule.setContent { + AppTheme { + RecipientPickerScreen(mode = mode) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentTest.kt new file mode 100644 index 000000000..80b2b29ae --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentTest.kt @@ -0,0 +1,217 @@ +package com.android.messaging.ui.conversation.recipientpicker + +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performSemanticsAction +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.component.row.contactItem +import com.android.messaging.ui.conversation.recipientpicker.component.row.recipientRowTestTag +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.selection.OnRecipientDestinationAction +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionStrings +import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RecipientSelectionContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun loadingState_showsProgressIndicatorAndHidesEmptyMessage() { + setContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + isLoading = true, + ), + ), + ) + + composeTestRule + .onAllNodes( + matcher = hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate), + ) + .assertCountEquals(expectedSize = 1) + composeTestRule + .onNodeWithText(targetContext.getString(R.string.contact_list_empty_text)) + .assertDoesNotExist() + } + + @Test + fun emptyItems_showsEmptyMessage() { + setContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState(), + ), + ) + + composeTestRule + .onNodeWithText(targetContext.getString(R.string.contact_list_empty_text)) + .assertIsDisplayed() + } + + @Test + fun primaryAction_isShownAndForwardsClicks() { + var primaryActionClicks = 0 + + setContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + items = persistentListOf(contactItem()), + ), + primaryAction = RecipientSelectionPrimaryActionUiState( + text = "Continue", + isEnabled = true, + testTag = RECIPIENT_SELECTION_PRIMARY_ACTION_TEST_TAG, + ), + ), + onPrimaryActionClick = { + primaryActionClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag(RECIPIENT_SELECTION_PRIMARY_ACTION_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, primaryActionClicks) + } + } + + @Test + fun longPressAndTrailingIndicator_areSupportedByRowDecorators() { + val item = contactItem() + var clickCount = 0 + var longClickedDestination: String? = null + + setContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + items = persistentListOf(item), + ), + ), + rowDecorators = RecipientSelectionRowDecorators( + recipientRowTestTag = { item -> + recipientRowTestTag(item = item) + }, + showRecipientTrailingIndicator = { _, _ -> true }, + trailingIndicatorTestTag = RECIPIENT_SELECTION_TRAILING_INDICATOR_TEST_TAG, + ), + onRecipientDestinationClick = { _, _ -> + clickCount += 1 + }, + onRecipientDestinationLongClick = { _, destination -> + longClickedDestination = destination + }, + ) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule + .onNodeWithTag(RECIPIENT_SELECTION_TRAILING_INDICATOR_TEST_TAG) + .assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals( + item.contact.destinations.first().normalizedValue, + longClickedDestination, + ) + assertEquals(0, clickCount) + } + } + + @Test + fun scrollingNearEnd_requestsLoadMoreWhenAllowed() { + var loadMoreCount = 0 + + setContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + items = persistentListOf( + *Array(size = 30) { index -> + contactItem( + id = index.toLong(), + displayName = "Contact $index", + destination = "+1 555 ${ + index.toString().padStart(length = 4, padChar = '0') + }", + normalizedDestination = "+1555${ + index.toString().padStart(length = 4, padChar = '0') + }", + ) + }, + ), + canLoadMore = true, + ), + ), + onLoadMore = { + loadMoreCount += 1 + }, + ) + + composeTestRule + .onNode(matcher = hasScrollToIndexAction()) + .performScrollToIndex(index = 29) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals(1, loadMoreCount) + } + } + + private fun setContent( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators = RecipientSelectionRowDecorators( + recipientRowTestTag = { item -> + recipientRowTestTag(item = item) + }, + ), + onLoadMore: () -> Unit = {}, + onPrimaryActionClick: () -> Unit = {}, + onRecipientDestinationClick: OnRecipientDestinationAction = { _, _ -> }, + onRecipientDestinationLongClick: OnRecipientDestinationAction? = null, + ) { + composeTestRule.setContent { + AppTheme { + RecipientSelectionContent( + uiState = uiState, + strings = RecipientSelectionStrings( + queryPrefixText = targetContext.getString(R.string.to_address_label), + queryPlaceholderText = targetContext.getString( + R.string.new_chat_query_hint + ), + ), + rowDecorators = rowDecorators, + onRecipientDestinationClick = onRecipientDestinationClick, + onLoadMore = onLoadMore, + onPrimaryActionClick = onPrimaryActionClick, + onQueryChanged = {}, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, + ) + } + } + } +} diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt similarity index 87% rename from app/src/androidTest/java/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt rename to app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt index b343f71bb..9229340e7 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCardTest.kt @@ -19,13 +19,13 @@ import kotlinx.collections.immutable.persistentListOf import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner -private const val PREFIX_TEXT = "To" -private const val PLACEHOLDER_TEXT = "Name, phone or email" - -internal class RecipientSelectionQueryCardTest { +@RunWith(RobolectricTestRunner::class) +class RecipientSelectionQueryCardTest { @get:Rule - val composeRule = createComposeRule() + val composeTestRule = createComposeRule() @Test fun softBackspaceRemovesEachChipInSequenceWhenStartingWithMultipleChips() { @@ -48,14 +48,14 @@ internal class RecipientSelectionQueryCardTest { val focusRequester = FocusRequester() var removeCount = 0 - composeRule.setContent { + composeTestRule.setContent { AppTheme { val uiState = RecipientSelectionQueryCardUiState( text = RecipientSelectionQueryTextUiState( query = "", enabled = true, prefixText = PREFIX_TEXT, - placeholderText = PLACEHOLDER_TEXT, + placeholderText = RECIPIENT_SELECTION_PLACEHOLDER_TEXT, ), chips = RecipientSelectionQueryChipsUiState( recipients = recipients, @@ -79,19 +79,19 @@ internal class RecipientSelectionQueryCardTest { } } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .performTextReplacement(text = "") - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, removeCount) } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .performTextReplacement(text = "") - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(2, removeCount) } } @@ -101,14 +101,14 @@ internal class RecipientSelectionQueryCardTest { var recipients by mutableStateOf(persistentListOf()) val focusRequester = FocusRequester() - composeRule.setContent { + composeTestRule.setContent { AppTheme { val uiState = RecipientSelectionQueryCardUiState( text = RecipientSelectionQueryTextUiState( query = "", enabled = true, prefixText = PREFIX_TEXT, - placeholderText = PLACEHOLDER_TEXT, + placeholderText = RECIPIENT_SELECTION_PLACEHOLDER_TEXT, ), chips = RecipientSelectionQueryChipsUiState( recipients = recipients, @@ -129,12 +129,12 @@ internal class RecipientSelectionQueryCardTest { } } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .requestFocus() .assertIsFocused() - composeRule.runOnIdle { + composeTestRule.runOnIdle { recipients = persistentListOf( SelectedRecipient( destination = "+3725400001", @@ -145,8 +145,12 @@ internal class RecipientSelectionQueryCardTest { ) } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .assertIsFocused() } + + private companion object { + private const val PREFIX_TEXT = "To" + } } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt similarity index 73% rename from app/src/androidTest/java/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt rename to app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt index 8b178cb59..0f929483d 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryFieldTest.kt @@ -15,16 +15,15 @@ import com.android.messaging.ui.conversation.recipientpicker.model.selection.Rec import com.android.messaging.ui.core.AppTheme import kotlinx.collections.immutable.toPersistentList import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner -private const val PLACEHOLDER_TEXT = "Name, phone or email" -private const val SENTINEL = "⁠" - -internal class RecipientSelectionQueryFieldTest { +@RunWith(RobolectricTestRunner::class) +class RecipientSelectionQueryFieldTest { @get:Rule - val composeRule = createComposeRule() + val composeTestRule = createComposeRule() @Test fun softBackspaceWithChipsAndEmptyQueryRemovesLastRecipient() { @@ -33,7 +32,7 @@ internal class RecipientSelectionQueryFieldTest { val focusRequester = FocusRequester() var lastRecipientRemoveCount = 0 - composeRule.setContent { + composeTestRule.setContent { AppTheme { RecipientSelectionQueryField( uiState = uiState, @@ -47,23 +46,54 @@ internal class RecipientSelectionQueryFieldTest { } } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .requestFocus() .performKeyInput { pressKey(key = Key.Backspace) } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, lastRecipientRemoveCount) } } + @Test + fun softBackspaceWithoutChipsAndEmptyQueryDoesNotRemoveRecipient() { + val uiState = queryFieldUiState(query = "", recipientCount = 0) + val state = TextFieldState(initialText = recipientSelectionQueryFieldEditableText(uiState)) + val focusRequester = FocusRequester() + var lastRecipientRemoveCount = 0 + + composeTestRule.setContent { + AppTheme { + RecipientSelectionQueryField( + uiState = uiState, + state = state, + onQueryFocusChanged = {}, + onLastSelectedRecipientRemove = { + lastRecipientRemoveCount += 1 + }, + focusRequester = focusRequester, + ) + } + } + + composeTestRule + .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) + .requestFocus() + .performKeyInput { pressKey(key = Key.Backspace) } + + composeTestRule.runOnIdle { + assertEquals(0, lastRecipientRemoveCount) + } + } + @Test fun typingFirstCharacterWithChipsEmitsQueryWithoutSentinel() { val uiState = queryFieldUiState(query = "", recipientCount = 1) val state = TextFieldState(initialText = recipientSelectionQueryFieldEditableText(uiState)) val focusRequester = FocusRequester() - composeRule.setContent { + composeTestRule.setContent { AppTheme { RecipientSelectionQueryField( uiState = uiState, @@ -75,14 +105,13 @@ internal class RecipientSelectionQueryFieldTest { } } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .performTextInput(text = "a") - composeRule.runOnIdle { + composeTestRule.runOnIdle { val currentText = state.text.toString() assertEquals("a", currentText) - assertNotEquals(SENTINEL + "a", currentText) } } @@ -93,7 +122,7 @@ internal class RecipientSelectionQueryFieldTest { val focusRequester = FocusRequester() var lastRecipientRemoveCount = 0 - composeRule.setContent { + composeTestRule.setContent { AppTheme { RecipientSelectionQueryField( uiState = uiState, @@ -107,12 +136,12 @@ internal class RecipientSelectionQueryFieldTest { } } - composeRule + composeTestRule .onNodeWithTag(testTag = RECIPIENT_SELECTION_QUERY_FIELD_TEST_TAG) .requestFocus() .performKeyInput { pressKey(key = Key.Backspace) } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(0, lastRecipientRemoveCount) } } @@ -133,7 +162,7 @@ internal class RecipientSelectionQueryFieldTest { return RecipientSelectionQueryFieldUiState( query = query, enabled = true, - placeholderText = PLACEHOLDER_TEXT, + placeholderText = RECIPIENT_SELECTION_PLACEHOLDER_TEXT, selectedRecipients = recipients.toPersistentList(), ) } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionSelectedRecipientChipsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionSelectedRecipientChipsTest.kt new file mode 100644 index 000000000..5bd86ab02 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionSelectedRecipientChipsTest.kt @@ -0,0 +1,199 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.recipientpicker.model.picker.SelectedRecipient +import com.android.messaging.ui.core.AppTheme +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class RecipientSelectionSelectedRecipientChipsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun unarmedChip_clickForwardsRecipientAndShowsFallbackAvatar() { + val recipient = selectedRecipient(label = "Ada") + val onRecipientClick = mockk<(SelectedRecipient) -> Unit>(relaxed = true) + + setContent( + recipients = persistentListOf(recipient), + onRecipientClick = onRecipientClick, + ) + + composeTestRule + .onNodeWithContentDescription(selectedRecipientDescription(label = "Ada")) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText(text = "A") + .assertIsDisplayed() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onRecipientClick.invoke(recipient) + } + } + } + + @Test + fun armedChip_usesRemoveDescriptionAndForwardsClick() { + val recipient = selectedRecipient(label = "Grace") + val onRecipientClick = mockk<(SelectedRecipient) -> Unit>(relaxed = true) + + setContent( + recipients = persistentListOf(recipient), + armedRecipientDestination = recipient.destination, + onRecipientClick = onRecipientClick, + ) + + composeTestRule + .onNodeWithContentDescription(removeRecipientDescription(label = "Grace")) + .assertIsSelected() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onRecipientClick.invoke(recipient) + } + } + } + + @Test + fun disabledChip_isNotClickable() { + val recipient = selectedRecipient(label = "Katherine") + val onRecipientClick = mockk<(SelectedRecipient) -> Unit>(relaxed = true) + + setContent( + recipients = persistentListOf(recipient), + enabled = false, + onRecipientClick = onRecipientClick, + ) + + composeTestRule + .onNodeWithContentDescription(selectedRecipientDescription(label = "Katherine")) + .assertIsNotEnabled() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onRecipientClick.invoke(any()) + } + } + } + + @Test + fun optionalLeadingAndTrailingContent_areRenderedAroundChips() { + setContent( + recipients = persistentListOf(selectedRecipient(label = "Lin")), + leadingContent = { + Text( + modifier = Modifier.testTag(tag = LEADING_CONTENT_TAG), + text = "Leading", + ) + }, + trailingContent = { modifier -> + Text( + modifier = modifier.testTag(tag = TRAILING_CONTENT_TAG), + text = "Trailing", + ) + }, + ) + + composeTestRule + .onNodeWithTag(testTag = LEADING_CONTENT_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(testTag = TRAILING_CONTENT_TAG) + .assertIsDisplayed() + } + + @Test + fun remoteAvatarUri_usesImageAvatarInsteadOfTextFallback() { + setContent( + recipients = persistentListOf( + selectedRecipient( + label = "Mira", + photoUri = "content://avatars/mira", + ), + ), + ) + + composeTestRule + .onNodeWithContentDescription(selectedRecipientDescription(label = "Mira")) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "M") + .assertDoesNotExist() + } + + private fun setContent( + recipients: ImmutableList, + armedRecipientDestination: String? = null, + enabled: Boolean = true, + onRecipientClick: (SelectedRecipient) -> Unit = mockk(relaxed = true), + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable (Modifier) -> Unit)? = null, + ) { + composeTestRule.setContent { + AppTheme { + RecipientSelectionSelectedRecipientChips( + recipients = recipients, + armedRecipientDestination = armedRecipientDestination, + enabled = enabled, + onRecipientClick = onRecipientClick, + leadingContent = leadingContent, + trailingContent = trailingContent, + ) + } + } + } + + private fun selectedRecipient(label: String, photoUri: String? = null): SelectedRecipient { + return SelectedRecipient( + destination = "+15550100-$label", + label = label, + displayDestination = "+1 555 0100", + photoUri = photoUri, + ) + } + + private fun selectedRecipientDescription(label: String): String { + return targetContext.getString( + R.string.recipient_selection_selected_recipient_content_description, + label, + ) + } + + @Suppress("SameParameterValue") + private fun removeRecipientDescription(label: String): String { + return targetContext.getString( + R.string.recipient_selection_remove_selected_recipient_content_description, + label, + ) + } + + private companion object { + private const val LEADING_CONTENT_TAG = "selected_recipient_chips_leading" + private const val TRAILING_CONTENT_TAG = "selected_recipient_chips_trailing" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowInteractionTest.kt new file mode 100644 index 000000000..e05f0f74d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowInteractionTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.click +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class MultiDestinationContactRowInteractionTest : BaseRecipientSelectionContactRowTest() { + + @Test + fun clickDestination_forwardsNormalizedValue() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent(item = item) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(WORK_EMAIL_NORMALIZED_DESTINATION) + } + verify(exactly = 0) { + onRowDestinationLongClick.invoke(any()) + } + } + + @Test + fun longClickDestination_forwardsNormalizedValue() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent(item = item) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = HOME_PHONE_DESTINATION)) + .performSemanticsAction(SemanticsActions.OnLongClick) + + verify(exactly = 1) { + onRowDestinationLongClick.invoke(HOME_PHONE_NORMALIZED_DESTINATION) + } + verify(exactly = 0) { + onRowDestinationClick.invoke(any()) + } + } + + @Test + fun destinationWithoutLongClickHandler_stillForwardsClick() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent(item = item, onDestinationLongClick = null) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(WORK_EMAIL_NORMALIZED_DESTINATION) + } + verify(exactly = 0) { + onRowDestinationLongClick.invoke(any()) + } + } + + @Test + fun disabledDestination_doesNotForwardClickOrLongClick() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent(item = item, enabled = false) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .performTouchInput { + click(position = center) + longClick(position = center) + } + composeTestRule.waitForIdle() + + verify(exactly = 0) { + onRowDestinationClick.invoke(any()) + } + verify(exactly = 0) { + onRowDestinationLongClick.invoke(any()) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowRenderingTest.kt new file mode 100644 index 000000000..6c6b21fac --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/MultiDestinationContactRowRenderingTest.kt @@ -0,0 +1,160 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.android.common.test.helpers.targetContext +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class MultiDestinationContactRowRenderingTest : BaseRecipientSelectionContactRowTest() { + + @Test + fun multiDestinationContact_rendersHeaderAndAllDestinations() { + setMultiDestinationRowContent() + + composeTestRule.onNodeWithText(CONTACT_DISPLAY_NAME).assertIsDisplayed() + composeTestRule.onNodeWithText(MOBILE_DESTINATION).assertIsDisplayed() + composeTestRule.onNodeWithText(WORK_EMAIL_DESTINATION).assertIsDisplayed() + composeTestRule.onNodeWithText(HOME_PHONE_DESTINATION).assertIsDisplayed() + } + + @Test + fun phoneAndEmailDestinations_usePlatformLabels() { + setMultiDestinationRowContent() + + composeTestRule + .onNodeWithText( + destinationLabel( + kind = DestinationLabelKind.PHONE, + type = Phone.TYPE_MOBILE, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + destinationLabel( + kind = DestinationLabelKind.EMAIL, + type = Email.TYPE_WORK, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + destinationLabel( + kind = DestinationLabelKind.PHONE, + type = Phone.TYPE_HOME, + ), + ) + .assertIsDisplayed() + } + + @Test + fun selectedDestination_setsSelectedSemanticsOnlyForMatchingDestination() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent( + item = item, + selectedDestinations = persistentSetOf(WORK_EMAIL_NORMALIZED_DESTINATION), + ) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .assertIsNotSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .assertIsSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = HOME_PHONE_DESTINATION)) + .assertIsNotSelected() + } + + @Test + fun adjacentSelectedDestinations_keepEachSelectedDestinationSelected() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent( + item = item, + selectedDestinations = persistentSetOf( + MOBILE_NORMALIZED_DESTINATION, + WORK_EMAIL_NORMALIZED_DESTINATION, + ), + ) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .assertIsSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .assertIsSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = HOME_PHONE_DESTINATION)) + .assertIsNotSelected() + } + + @Test + fun nonAdjacentSelectedDestinations_keepSeparatedDestinationsSelected() { + val item = multiDestinationContactItem() + + setMultiDestinationRowContent( + item = item, + selectedDestinations = persistentSetOf( + MOBILE_NORMALIZED_DESTINATION, + HOME_PHONE_NORMALIZED_DESTINATION, + ), + ) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .assertIsSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .assertIsNotSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = HOME_PHONE_DESTINATION)) + .assertIsSelected() + } + + @Test + fun trailingIndicatorVisibleForSpecificDestination() { + setMultiDestinationRowContent( + rowDecorators = defaultRowDecorators( + showTrailingIndicator = { _, destination -> + destination == WORK_EMAIL_NORMALIZED_DESTINATION + }, + ), + ) + + composeTestRule + .onAllNodesWithTag(TRAILING_INDICATOR_TEST_TAG) + .assertCountEquals(expectedSize = 1) + } + + private fun destinationLabel(kind: DestinationLabelKind, type: Int): String { + val label = when (kind) { + DestinationLabelKind.PHONE -> { + Phone.getTypeLabel(targetContext.resources, type, null) + } + + DestinationLabelKind.EMAIL -> { + Email.getTypeLabel(targetContext.resources, type, null) + } + } + + return label.toString() + } + + private enum class DestinationLabelKind { + EMAIL, + PHONE, + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowRoutingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowRoutingTest.kt new file mode 100644 index 000000000..ae1693ea6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowRoutingTest.kt @@ -0,0 +1,178 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class RecipientSelectionContactRowRoutingTest : BaseRecipientSelectionContactRowTest() { + + @Test + fun singleDestinationContact_routesClickToNormalizedDestination() { + val item = singleDestinationContactItem() + + setContactRowContent(item = item) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(MOBILE_NORMALIZED_DESTINATION) + } + } + + @Test + fun singleDestinationContact_routesLongClickToNormalizedDestination() { + val item = singleDestinationContactItem() + + setContactRowContent(item = item) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performSemanticsAction(SemanticsActions.OnLongClick) + + verify(exactly = 1) { + onRowDestinationLongClick.invoke(MOBILE_NORMALIZED_DESTINATION) + } + verify(exactly = 0) { + onRowDestinationClick.invoke(any()) + } + } + + @Test + fun singleDestinationContact_withoutLongClickHandlerStillRoutesClick() { + val item = singleDestinationContactItem() + + setContactRowContent(item = item, onDestinationLongClick = null) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(MOBILE_NORMALIZED_DESTINATION) + } + verify(exactly = 0) { + onRowDestinationLongClick.invoke(any()) + } + } + + @Test + fun selectedSingleDestinationContact_setsRowSelectedSemantics() { + val item = singleDestinationContactItem() + + setContactRowContent( + item = item, + selectedDestinations = persistentSetOf(MOBILE_NORMALIZED_DESTINATION), + ) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .assertIsSelected() + } + + @Test + fun multiDestinationContact_routesSpecificDestinationClick() { + val item = multiDestinationContactItem() + + setContactRowContent(item = item) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(WORK_EMAIL_NORMALIZED_DESTINATION) + } + } + + @Test + fun syntheticPhone_routesClickToNormalizedDestination() { + val item = syntheticPhoneItem() + + setContactRowContent(item = item) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(SYNTHETIC_PHONE_NORMALIZED_DESTINATION) + } + } + + @Test + fun syntheticPhone_withoutLongClickHandlerStillRoutesClick() { + val item = syntheticPhoneItem() + + setContactRowContent(item = item, onDestinationLongClick = null) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performClick() + + verify(exactly = 1) { + onRowDestinationClick.invoke(SYNTHETIC_PHONE_NORMALIZED_DESTINATION) + } + verify(exactly = 0) { + onRowDestinationLongClick.invoke(any()) + } + } + + @Test + fun syntheticPhone_routesLongClickToNormalizedDestination() { + val item = syntheticPhoneItem() + + setContactRowContent(item = item) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .performSemanticsAction(SemanticsActions.OnLongClick) + + verify(exactly = 1) { + onRowDestinationLongClick.invoke(SYNTHETIC_PHONE_NORMALIZED_DESTINATION) + } + } + + @Test + fun selectedSyntheticPhone_setsRowSelectedSemantics() { + val item = syntheticPhoneItem() + + setContactRowContent( + item = item, + selectedDestinations = persistentSetOf(SYNTHETIC_PHONE_NORMALIZED_DESTINATION), + ) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .assertIsDisplayed() + .assertIsSelected() + } + + @Test + fun contactWithoutDestinations_rendersHeaderWithoutDestinationRows() { + val item = contactWithoutDestinations() + + setContactRowContent(item = item) + + composeTestRule + .onNodeWithTag(recipientRowTestTag(item = item)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(EMPTY_CONTACT_DISPLAY_NAME) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .assertDoesNotExist() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowShapeTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowShapeTest.kt new file mode 100644 index 000000000..01367a429 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContactRowShapeTest.kt @@ -0,0 +1,48 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import androidx.compose.foundation.shape.RoundedCornerShape +import com.android.messaging.ui.conversation.recipientpicker.component.contactCornerRadius +import com.android.messaging.ui.conversation.recipientpicker.component.contactMiddleCornerRadius +import com.android.messaging.ui.conversation.recipientpicker.component.recipientSelectionContactRowShape +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class RecipientSelectionContactRowShapeTest { + + @Test + fun recipientSelectionContactRowShape_mapsListPositionToGroupedShape() { + assertEquals( + RoundedCornerShape(size = contactCornerRadius), + recipientSelectionContactRowShape(index = 0, totalCount = 0), + ) + assertEquals( + RoundedCornerShape(size = contactCornerRadius), + recipientSelectionContactRowShape(index = 0, totalCount = 1), + ) + assertEquals( + RoundedCornerShape( + topStart = contactCornerRadius, + topEnd = contactCornerRadius, + bottomStart = contactMiddleCornerRadius, + bottomEnd = contactMiddleCornerRadius, + ), + recipientSelectionContactRowShape(index = 0, totalCount = 3), + ) + assertEquals( + RoundedCornerShape(size = contactMiddleCornerRadius), + recipientSelectionContactRowShape(index = 1, totalCount = 3), + ) + assertEquals( + RoundedCornerShape( + topStart = contactMiddleCornerRadius, + topEnd = contactMiddleCornerRadius, + bottomStart = contactCornerRadius, + bottomEnd = contactCornerRadius, + ), + recipientSelectionContactRowShape(index = 2, totalCount = 3), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContentMultiDestinationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContentMultiDestinationTest.kt new file mode 100644 index 000000000..4cb6d5d23 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/row/RecipientSelectionContentMultiDestinationTest.kt @@ -0,0 +1,98 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.row + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.android.messaging.ui.conversation.recipientpicker.model.picker.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.model.selection.RecipientSelectionPrimaryActionUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class RecipientSelectionContentMultiDestinationTest : + BaseRecipientSelectionContactRowTest() { + + @Test + fun multiDestinationContent_forwardsSpecificItemAndNormalizedDestination() { + val item = multiDestinationContactItem() + + setSelectionContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + items = persistentListOf(item), + ), + ), + ) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .performClick() + + verify(exactly = 1) { + onContentDestinationClick.invoke(item, WORK_EMAIL_NORMALIZED_DESTINATION) + } + } + + @Test + fun selectedRecipients_markMatchingMultiDestinationOnly() { + val item = multiDestinationContactItem() + + setSelectionContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + items = persistentListOf(item), + ), + selectedRecipients = persistentListOf( + selectedRecipient( + destination = WORK_EMAIL_NORMALIZED_DESTINATION, + displayDestination = WORK_EMAIL_DESTINATION, + ), + ), + ), + ) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .assertIsNotSelected() + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = WORK_EMAIL_DESTINATION)) + .assertIsSelected() + } + + @Test + fun loadingPrimaryAction_disablesMultiDestinationRows() { + val item = multiDestinationContactItem() + + setSelectionContent( + uiState = RecipientSelectionContentUiState( + picker = RecipientPickerUiState( + items = persistentListOf(item), + ), + primaryAction = RecipientSelectionPrimaryActionUiState( + text = "Next", + isEnabled = false, + isLoading = true, + testTag = null, + ), + ), + ) + + composeTestRule + .onNodeWithTag(destinationRowTestTag(item = item, destination = MOBILE_DESTINATION)) + .performTouchInput { + click(position = center) + } + composeTestRule.waitForIdle() + + verify(exactly = 0) { + onContentDestinationClick.invoke(any(), any()) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelectorRowTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelectorRowTest.kt new file mode 100644 index 000000000..22ca65e84 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelectorRowTest.kt @@ -0,0 +1,224 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.simselector + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG +import com.android.messaging.ui.conversation.TEST_ATT_SUBSCRIPTION_NAME +import com.android.messaging.ui.conversation.TEST_VERIZON_SUBSCRIPTION_NAME +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.newChatSimSelectorItemTestTag +import com.android.messaging.ui.conversation.testAttSubscription +import com.android.messaging.ui.conversation.testVerizonSubscription +import com.android.messaging.ui.core.AppTheme +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class NewChatSimSelectorRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val onSimSelected = mockk<(String) -> Unit>(relaxed = true) + + @Before + fun setUpNewChatSimSelectorRowTest() { + clearAllMocks() + } + + @Test + fun unavailable_doesNotRender() { + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testVerizonSubscription, + isLoading = true, + ), + ) + + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun singleSubscription_doesNotRender() { + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription), + selectedSubscription = testVerizonSubscription, + ), + ) + + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun availableWithoutSelectedSubscription_doesNotRender() { + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = null, + ), + ) + + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun available_rendersChipWithSelectedLabel() { + setContent() + + val prefix = targetContext.getString(R.string.new_chat_sim_selector_prefix) + val chipDescription = targetContext.getString( + R.string.new_chat_sim_selector_chip_content_description, + TEST_VERIZON_SUBSCRIPTION_NAME, + ) + + composeTestRule.onNodeWithText(prefix).assertIsDisplayed() + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .assertIsDisplayed() + composeTestRule.onNodeWithText(TEST_VERIZON_SUBSCRIPTION_NAME).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(chipDescription).assertIsDisplayed() + } + + @Test + fun chipClick_opensDropdown() { + setContent() + + openDropdown() + + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag( + newChatSimSelectorItemTestTag( + selfParticipantId = testVerizonSubscription.selfParticipantId, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag( + newChatSimSelectorItemTestTag( + selfParticipantId = testAttSubscription.selfParticipantId, + ), + ) + .assertIsDisplayed() + } + + @Test + fun dropdown_rendersDestinationWhenPresent() { + setContent() + + openDropdown() + + composeTestRule + .onNodeWithText(testVerizonSubscription.displayDestination.orEmpty()) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(testAttSubscription.displayDestination.orEmpty()) + .assertIsDisplayed() + } + + @Test + fun dropdown_omitsDestinationWhenNull() { + val subscriptionWithoutDestination = testAttSubscription.copy(displayDestination = null) + + setContent( + uiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf( + testVerizonSubscription, + subscriptionWithoutDestination, + ), + selectedSubscription = testVerizonSubscription, + ), + ) + + openDropdown() + + composeTestRule.onNodeWithText(TEST_ATT_SUBSCRIPTION_NAME).assertIsDisplayed() + composeTestRule + .onNodeWithText(testAttSubscription.displayDestination.orEmpty()) + .assertDoesNotExist() + } + + @Test + fun selectedSubscription_showsSelectedIconOnlyOnce() { + setContent() + + openDropdown() + + val selectedDescription = targetContext.getString(R.string.sim_selector_item_selected) + + composeTestRule + .onAllNodesWithContentDescription(selectedDescription) + .assertCountEquals(expectedSize = 1) + } + + @Test + fun selectingSubscription_callsCallbackAndDismisses() { + setContent() + + openDropdown() + composeTestRule + .onNodeWithTag( + newChatSimSelectorItemTestTag( + selfParticipantId = testAttSubscription.selfParticipantId, + ), + ) + .performClick() + + composeTestRule.waitForIdle() + + verify(exactly = 1) { + onSimSelected.invoke(testAttSubscription.selfParticipantId) + } + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG) + .assertDoesNotExist() + } + + private fun setContent( + uiState: ConversationSimSelectorUiState = ConversationSimSelectorUiState( + subscriptions = persistentListOf(testVerizonSubscription, testAttSubscription), + selectedSubscription = testVerizonSubscription, + ), + ) { + composeTestRule.setContent { + AppTheme { + NewChatSimSelectorRow( + uiState = uiState, + onSimSelected = onSimSelected, + ) + } + } + } + + private fun openDropdown() { + composeTestRule + .onNodeWithTag(NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .performClick() + } +} From a2673b5aea2b0546848f09669ef1746678731ec4 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:15:55 +0300 Subject: [PATCH 22/38] Cover conversation screen flows --- .../screen/ConversationScreenEffectsTest.kt | 39 +- .../ConversationScreenInteractionTest.kt | 222 +++++++++++ .../ConversationScreenLaunchPayloadsTest.kt | 58 +++ .../screen/ConversationScreenRenderingTest.kt | 140 +++++++ .../ConversationScreenScrollBehaviorTest.kt | 156 ++++++++ .../ConversationScreenSelectionBackTest.kt | 43 +++ .../ConversationScreenSelectionModeTest.kt | 233 ++++++++++++ .../ConversationScreenTopAppBarActionsTest.kt | 199 ++++++++++ ...ersationScreenAttachmentLimitDialogTest.kt | 109 ++++++ .../ConversationScreenDeleteDialogsTest.kt | 98 +++++ ...ationScreenSubjectDialogPlaceholderTest.kt | 58 +++ .../ConversationScreenSubjectDialogTest.kt | 68 ++++ ...versationScreenDefaultSmsRoleEffectTest.kt | 140 +++++++ .../ConversationScreenDraftSentEffectTest.kt | 64 ++++ .../ConversationScreenImmediateEffectsTest.kt | 232 ++++++++++++ .../ConversationScreenRouteCallbackTest.kt | 348 ++++++++++++++++++ .../ConversationScreenRouteEffectsTest.kt | 343 +++++++++++++++++ ...onSelectionTopAppBarResidualActionsTest.kt | 134 +++++++ ...eenSendButtonSimSelectorIntegrationTest.kt | 81 ++++ ...rsationScreenSimSelectorIntegrationTest.kt | 84 +++++ 20 files changed, 2832 insertions(+), 17 deletions(-) rename app/src/{androidTest/java => test/kotlin}/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt (84%) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenInteractionTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenLaunchPayloadsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenRenderingTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenScrollBehaviorTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionBackTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionModeTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenTopAppBarActionsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenAttachmentLimitDialogTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogPlaceholderTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDefaultSmsRoleEffectTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDraftSentEffectTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenImmediateEffectsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteCallbackTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteEffectsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/selection/ConversationSelectionTopAppBarResidualActionsTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSendButtonSimSelectorIntegrationTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSimSelectorIntegrationTest.kt diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt similarity index 84% rename from app/src/androidTest/java/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt rename to app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt index 84ee644f1..9186e0d1f 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState @@ -19,13 +20,13 @@ import kotlinx.coroutines.flow.callbackFlow import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner -private const val CAPTURED_INPUT_CHANGE_COUNT = 5 -private const val TEST_WAIT_TIMEOUT_MILLIS = 5_000L - -internal class ConversationScreenEffectsTest { +@RunWith(RobolectricTestRunner::class) +class ConversationScreenEffectsTest { @get:Rule - val composeRule = createComposeRule() + val composeTestRule = createComposeRule() @Test fun changingCapturedHandlerInputsDoesNotRestartEffectsCollector() { @@ -41,7 +42,7 @@ internal class ConversationScreenEffectsTest { ) val inputGeneration = mutableStateOf(value = 0) - composeRule.setContent { + composeTestRule.setContent { val snackbarHostState = remember(inputGeneration.value) { SnackbarHostState() } @@ -60,18 +61,18 @@ internal class ConversationScreenEffectsTest { ) } - composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { subscriptionCount.get() == 1 } repeat(CAPTURED_INPUT_CHANGE_COUNT) { changeIndex -> - composeRule.runOnIdle { + composeTestRule.runOnIdle { inputGeneration.value = changeIndex + 1 } - composeRule.waitForIdle() + composeTestRule.waitForIdle() } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, subscriptionCount.get()) assertEquals(0, cancellationCount.get()) } @@ -87,7 +88,7 @@ internal class ConversationScreenEffectsTest { val currentNavigateBackCount = AtomicInteger() val inputGeneration = mutableStateOf(value = 0) - composeRule.setContent { + composeTestRule.setContent { val snackbarHostState = remember(inputGeneration.value) { SnackbarHostState() } @@ -118,25 +119,25 @@ internal class ConversationScreenEffectsTest { ) } - composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { effectFlow.subscriptionCount.value == 1 } - composeRule.runOnIdle { + composeTestRule.runOnIdle { inputGeneration.value = 1 } - composeRule.waitForIdle() + composeTestRule.waitForIdle() - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(1, effectFlow.subscriptionCount.value) assertEquals(true, effectFlow.tryEmit(ConversationScreenEffect.CloseConversation)) } - composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { currentNavigateBackCount.get() == 1 } - composeRule.runOnIdle { + composeTestRule.runOnIdle { assertEquals(0, staleNavigateBackCount.get()) assertEquals(1, currentNavigateBackCount.get()) } @@ -155,4 +156,8 @@ internal class ConversationScreenEffectsTest { ) } } + + private companion object { + private const val CAPTURED_INPUT_CHANGE_COUNT = 5 + } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenInteractionTest.kt new file mode 100644 index 000000000..7dbd12bd6 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenInteractionTest.kt @@ -0,0 +1,222 @@ +package com.android.messaging.ui.conversation.screen + +import android.Manifest +import android.app.Application +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag +import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import io.mockk.every +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenInteractionTest : BaseConversationScreenTest() { + + @Test + fun longPressingRecordButton_whenAudioPermissionGrantedStartsUnlockedRecording() { + grantAudioRecordingPermission() + val screenModel = createScreenModel() + val uiState = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + ) + every { screenModel.model.tryStartAddingAttachment() } returns true + screenModel.scaffoldUiStateFlow.value = uiState.copy( + composer = uiState.composer.copy( + isSendEnabled = false, + isRecordActionEnabled = true, + shouldShowRecordAction = true, + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + advanceEventTime(durationMillis = 700L) + up() + } + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.tryStartAddingAttachment() + } + verify(exactly = 1) { + screenModel.model.onAudioRecordingStart() + } + verify(exactly = 0) { + screenModel.model.onLockedAudioRecordingStart() + } + } + } + + @Test + fun clickingAudioAttachmentMenuItem_whenAudioPermissionGrantedStartsLockedRecording() { + grantAudioRecordingPermission() + val screenModel = createScreenModel() + val uiState = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + ) + every { screenModel.model.tryStartAddingAttachment() } returns true + screenModel.scaffoldUiStateFlow.value = uiState.copy( + composer = uiState.composer.copy( + isAttachmentActionEnabled = true, + isRecordActionEnabled = true, + messageText = "Message", + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.tryStartAddingAttachment() + } + verify(exactly = 0) { + screenModel.model.onAudioRecordingStart() + } + verify(exactly = 1) { + screenModel.model.onLockedAudioRecordingStart() + } + } + } + + @Test + fun longPressingMessage_forwardsLongClickToScreenModel() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(conversationMessageBubbleTestTag(messageId = "message-3")) + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageLongClick(messageId = "message-3") + } + } + } + + @Test + fun clickingMessageInSelectionMode_forwardsClickToScreenModel() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-3"), + availableActions = persistentSetOf( + ConversationMessageSelectionAction.Copy, + ConversationMessageSelectionAction.Delete, + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(conversationMessageSelectionRowTestTag(messageId = "message-2")) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageClick(messageId = "message-2") + } + } + } + + @Test + fun clickingFailedMessage_forwardsResendClickToScreenModel() { + val screenModel = createScreenModel() + val failedStatusText = targetContext.getString(R.string.message_status_send_failed) + val messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ).map { message -> + when (message.messageId) { + "message-2" -> { + message.copy( + status = ConversationMessageUiModel.Status.Outgoing.Failed, + canResendMessage = true, + ) + } + + else -> message + } + } + + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = messages, + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithText(failedStatusText, substring = true) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(conversationMessageBubbleTestTag(messageId = "message-2")) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageResendClick(messageId = "message-2") + } + } + } + + private fun grantAudioRecordingPermission() { + Shadows + .shadowOf(targetContext as Application) + .grantPermissions(Manifest.permission.RECORD_AUDIO) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenLaunchPayloadsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenLaunchPayloadsTest.kt new file mode 100644 index 000000000..57c9fe342 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenLaunchPayloadsTest.kt @@ -0,0 +1,58 @@ +package com.android.messaging.ui.conversation.screen + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenLaunchPayloadsTest : BaseConversationScreenTest() { + + @Test + fun pendingLaunchPayloads_seedDraftOpenAttachmentAndNotifyConsumption() { + val screenModel = createScreenModel() + var draftConsumedCount = 0 + var attachmentConsumedCount = 0 + val pendingDraft = ConversationDraft( + messageText = "Hello", + ) + val pendingAttachment = ConversationEntryStartupAttachment( + contentType = "image/jpeg", + contentUri = "content://media/image/1", + ) + + setContent( + screenModel = screenModel.model, + pendingDraft = pendingDraft, + pendingStartupAttachment = pendingAttachment, + onPendingDraftConsumed = { + draftConsumedCount += 1 + }, + onPendingStartupAttachmentConsumed = { + attachmentConsumedCount += 1 + }, + ) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onSeedDraft( + conversationId = CONVERSATION_ID, + draft = pendingDraft, + ) + } + verify(exactly = 1) { + screenModel.model.onOpenStartupAttachment( + conversationId = CONVERSATION_ID, + startupAttachment = pendingAttachment, + ) + } + assertEquals(1, draftConsumedCount) + assertEquals(1, attachmentConsumedCount) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenRenderingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenRenderingTest.kt new file mode 100644 index 000000000..d8047ccb4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenRenderingTest.kt @@ -0,0 +1,140 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import com.android.messaging.testutil.TEST_CONVERSATION_ID +import com.android.messaging.ui.conversation.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_LOADING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.core.AppTheme +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenRenderingTest : BaseConversationScreenTest() { + + @Test + fun defaultOptionalParameters_renderLoadingState() { + val screenModel = createScreenModel() + + composeTestRule.setContent { + AppTheme { + ConversationScreen( + onAddPeopleClick = {}, + onConversationDetailsClick = {}, + onNavigateBack = {}, + screenModel = screenModel.model, + ) + } + } + + composeTestRule + .onNodeWithTag(CONVERSATION_LOADING_INDICATOR_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun loadingState_showsLoadingIndicator() { + val screenModel = createScreenModel() + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_LOADING_INDICATOR_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun presentState_showsMessagesList() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 8, + latestMessageId = "message-8", + latestMessageIncoming = false, + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_LOADING_INDICATOR_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun presentMessagesWithLoadingSimSelector_showsLoadingIndicator() { + val screenModel = createScreenModel() + val uiState = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + ) + screenModel.scaffoldUiStateFlow.value = uiState.copy( + composer = uiState.composer.copy( + simSelector = ConversationSimSelectorUiState( + isLoading = true, + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_LOADING_INDICATOR_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun scaffold_whenMediaPickerOpen_hidesComposerBottomBar() { + val screenModel = createScreenModel() + val uiState = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + ) + + composeTestRule.setContent { + AppTheme { + ConversationScreenScaffold( + conversationId = TEST_CONVERSATION_ID, + uiState = uiState, + snackbarHostState = SnackbarHostState(), + isMediaPickerOpen = true, + messageFieldFocusRequester = FocusRequester(), + pendingScrollPosition = null, + onPendingScrollPositionConsumed = {}, + onAddPeopleClick = {}, + onConversationDetailsClick = {}, + onNavigateBack = {}, + onOpenContactPicker = {}, + onOpenMediaPicker = {}, + onAudioRecordingStartRequest = {}, + onLockedAudioRecordingStartRequest = {}, + screenModel = screenModel.model, + ) + } + } + + composeTestRule + .onNodeWithTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_COMPOSE_BAR_TEST_TAG) + .assertDoesNotExist() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenScrollBehaviorTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenScrollBehaviorTest.kt new file mode 100644 index 000000000..f5bc8097c --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenScrollBehaviorTest.kt @@ -0,0 +1,156 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollToIndex +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageItemTestTag +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenScrollBehaviorTest : BaseConversationScreenTest() { + + @Test + fun outgoingInsert_scrollsToLatestMessage() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 30, + latestMessageId = "message-30", + latestMessageIncoming = false, + ), + ) + + setContent(screenModel = screenModel.model) + composeTestRule + .onNodeWithTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .performScrollToIndex(index = 20) + composeTestRule.waitForIdle() + + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 31, + latestMessageId = "message-31", + latestMessageIncoming = false, + ), + ) + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(conversationMessageItemTestTag(messageId = "message-31")) + .assertIsDisplayed() + } + + @Test + fun conversationChange_resetsListStateToLatestMessage() { + val screenModel = createScreenModel() + var conversationState by mutableStateOf( + Pair( + CONVERSATION_ID, + 1, + ), + ) + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 30, + latestMessageId = "conversation-1-message-30", + latestMessageIncoming = false, + messageIdPrefix = "conversation-1-message", + ), + ) + + setContent( + screenModel = screenModel.model, + conversationId = { conversationState.first }, + launchGeneration = { conversationState.second }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .performScrollToIndex(index = 20) + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(conversationMessageItemTestTag(messageId = "conversation-1-message-30")) + .assertDoesNotExist() + + composeTestRule.runOnIdle { + conversationState = Pair( + "conversation-2", + 2, + ) + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 5, + latestMessageId = "conversation-2-message-5", + latestMessageIncoming = false, + messageIdPrefix = "conversation-2-message", + ), + ) + } + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(conversationMessageItemTestTag(messageId = "conversation-2-message-5")) + .assertIsDisplayed() + } + + @Test + fun pendingScrollPosition_anchorsTargetMessage_andInvokesConsumed() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 50, + latestMessageId = "message-50", + latestMessageIncoming = false, + ), + ) + var consumedCount = 0 + + setContent( + screenModel = screenModel.model, + pendingScrollPosition = 5, + onPendingScrollPositionConsumed = { consumedCount += 1 }, + ) + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(conversationMessageItemTestTag(messageId = "message-6")) + .assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals(1, consumedCount) + } + } + + @Test + fun nullPendingScrollPosition_doesNotInvokeConsumed() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 8, + latestMessageId = "message-8", + latestMessageIncoming = false, + ), + ) + var consumedCount = 0 + + setContent( + screenModel = screenModel.model, + pendingScrollPosition = null, + onPendingScrollPositionConsumed = { consumedCount += 1 }, + ) + + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals(0, consumedCount) + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionBackTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionBackTest.kt new file mode 100644 index 000000000..f54c6de48 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionBackTest.kt @@ -0,0 +1,43 @@ +package com.android.messaging.ui.conversation.screen + +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenSelectionBackTest : BaseConversationScreenTest() { + + @Test + fun systemBackInSelectionMode_dismissesMessageSelection() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-2"), + availableActions = persistentSetOf( + ConversationMessageSelectionAction.Delete, + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule.runOnIdle { + composeTestRule.activity.onBackPressedDispatcher.onBackPressed() + } + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.dismissMessageSelection() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionModeTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionModeTest.kt new file mode 100644 index 000000000..d0f6851ec --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenSelectionModeTest.kt @@ -0,0 +1,233 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageSelectionActionButtonTestTag +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenSelectionModeTest : BaseConversationScreenTest() { + + @Test + fun singleSelection_showsCopyAndDeleteInTopAppBar_andForwardsActionClicks() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 1, + latestMessageId = "message-1", + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-1"), + availableActions = persistentSetOf( + ConversationMessageSelectionAction.Copy, + ConversationMessageSelectionAction.Delete, + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.Copy.name, + ), + ) + .assertIsDisplayed() + .performClick() + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Copy, + ) + } + } + + composeTestRule + .onNodeWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.Delete.name, + ), + ) + .assertIsDisplayed() + .performClick() + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Delete, + ) + } + } + + composeTestRule + .onNodeWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.Copy.name, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.Delete.name, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun singleSelectionWithSaveAttachment_showsSaveActionInOverflow_andForwardsClick() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 1, + latestMessageId = "message-1", + latestMessageIncoming = true, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-1"), + availableActions = persistentSetOf( + ConversationMessageSelectionAction.Delete, + ConversationMessageSelectionAction.SaveAttachment, + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onAllNodesWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.SaveAttachment.name, + ), + ) + .assertCountEquals(expectedSize = 0) + + composeTestRule + .onNodeWithTag(CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.SaveAttachment.name, + ), + ) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.SaveAttachment, + ) + } + } + } + + @Test + fun multiSelection_showsOnlyDeleteActionInTopAppBar() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 2, + latestMessageId = "message-2", + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-1", "message-2"), + availableActions = persistentSetOf( + ConversationMessageSelectionAction.Delete, + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithText( + targetContext.resources.getQuantityString( + R.plurals.conversation_message_selection_title, + 2, + 2, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.Delete.name, + ), + ) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithTag( + conversationMessageSelectionActionButtonTestTag( + action = ConversationMessageSelectionAction.Copy.name, + ), + ) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun deleteConfirmationButtons_forwardToScreenModel() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 1, + latestMessageId = "message-1", + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-1"), + availableActions = persistentSetOf( + ConversationMessageSelectionAction.Delete, + ), + deleteConfirmation = ConversationMessageDeleteConfirmationUiState( + messageIds = persistentSetOf("message-1"), + ), + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG) + .performClick() + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.dismissDeleteMessageConfirmation() + } + } + + composeTestRule + .onNodeWithTag(CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG) + .performClick() + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.confirmDeleteSelectedMessages() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenTopAppBarActionsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenTopAppBarActionsTest.kt new file mode 100644 index 000000000..a65aa5844 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenTopAppBarActionsTest.kt @@ -0,0 +1,199 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER +import com.android.messaging.ui.conversation.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_CALL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenTopAppBarActionsTest : BaseConversationScreenTest() { + + @Test + fun addPeopleAction_isShownInOverflowWhenEnabled_andForwardsClicks() { + val screenModel = createScreenModel() + var addPeopleClicks = 0 + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + canAddPeople = true, + ) + + setContent( + screenModel = screenModel.model, + onAddPeopleClick = { + addPeopleClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG) + .assertDoesNotExist() + + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + assertEquals(1, addPeopleClicks) + } + } + + @Test + fun callAction_isShownWhenEnabled_andForwardsClicks() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + canCall = true, + otherParticipantPhoneNumber = TEST_CALL_ACTION_PHONE_NUMBER, + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_CALL_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onCallClick() + } + } + } + + @Test + fun callAction_isHiddenWhenDisabled() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + canCall = false, + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag(CONVERSATION_CALL_BUTTON_TEST_TAG) + .assertDoesNotExist() + } + + @Test + fun overflowActions_whenConversationIsUnarchivedForwardScreenModelClicks() { + val screenModel = createScreenModel() + setTopBarActionContent( + screenModel = screenModel, + canArchive = true, + canAddContact = true, + canDeleteConversation = true, + canEditSubject = true, + ) + + openOverflowMenuAndClickItem(menuItemTestTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG) + openOverflowMenuAndClickItem(menuItemTestTag = CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG) + openOverflowMenuAndClickItem( + menuItemTestTag = CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG, + ) + openOverflowMenuAndClickItem( + menuItemTestTag = CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG, + ) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onArchiveConversationClick() + } + verify(exactly = 1) { + screenModel.model.onAddContactClick() + } + verify(exactly = 1) { + screenModel.model.onShowSubjectFieldClick() + } + verify(exactly = 1) { + screenModel.model.onDeleteConversationClick() + } + } + } + + @Test + fun overflowActions_whenConversationIsArchivedForwardsUnarchiveClick() { + val screenModel = createScreenModel() + setTopBarActionContent( + screenModel = screenModel, + canUnarchive = true, + isArchived = true, + ) + + openOverflowMenuAndClickItem(menuItemTestTag = CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onUnarchiveConversationClick() + } + } + } + + private fun setTopBarActionContent( + screenModel: ScreenModelHandle, + canArchive: Boolean = false, + canUnarchive: Boolean = false, + canAddContact: Boolean = false, + canDeleteConversation: Boolean = false, + canEditSubject: Boolean = false, + isArchived: Boolean = false, + ) { + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + canArchive = canArchive, + canUnarchive = canUnarchive, + canAddContact = canAddContact, + canDeleteConversation = canDeleteConversation, + isArchived = isArchived, + ).copy(canEditSubject = canEditSubject) + + setContent(screenModel = screenModel.model) + } + + private fun openOverflowMenuAndClickItem(menuItemTestTag: String) { + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithTag(menuItemTestTag) + .assertIsDisplayed() + .performClick() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenAttachmentLimitDialogTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenAttachmentLimitDialogTest.kt new file mode 100644 index 000000000..ca7c7096f --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenAttachmentLimitDialogTest.kt @@ -0,0 +1,109 @@ +package com.android.messaging.ui.conversation.screen.dialogs + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.messaging.R +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenAttachmentLimitDialogTest : BaseConversationScreenDialogsTest() { + + @Test + fun composingAttachmentLimit_showsDismissOnlyMessage() { + val screenModel = setDialogsContent( + uiState = createDialogUiState( + attachmentLimitWarning = + ConversationAttachmentLimitWarning.ComposingAttachmentLimitReached, + ), + ) + + composeTestRule + .onNodeWithText(text(R.string.mms_attachment_limit_reached)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text(R.string.attachment_limit_reached_dialog_message_when_composing)) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text(R.string.attachment_limit_reached_send_anyway)) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onNodeWithText(okText()) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.dismissAttachmentLimitWarning() + } + verify(exactly = 0) { + screenModel.sendAnywayAfterAttachmentLimitWarning() + } + } + } + + @Test + fun sendingMessageLimit_showsSendAnywayAndDismissActions() { + val screenModel = setDialogsContent( + uiState = createDialogUiState( + attachmentLimitWarning = + ConversationAttachmentLimitWarning.SendingMessageLimitReached, + ), + ) + + composeTestRule + .onNodeWithText(text(R.string.attachment_limit_reached_dialog_message_when_sending)) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text(R.string.attachment_limit_reached_send_anyway)) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithText(okText()) + .assertIsDisplayed() + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.sendAnywayAfterAttachmentLimitWarning() + } + verify(exactly = 1) { + screenModel.dismissAttachmentLimitWarning() + } + } + } + + @Test + fun sendingVideoLimit_showsVideoSpecificDismissOnlyMessage() { + val screenModel = setDialogsContent( + uiState = createDialogUiState( + attachmentLimitWarning = + ConversationAttachmentLimitWarning.SendingVideoAttachmentLimitReached, + ), + ) + + composeTestRule + .onNodeWithText(text(R.string.video_attachment_limit_exceeded_when_sending)) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text(R.string.attachment_limit_reached_send_anyway)) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onNodeWithText(okText()) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.dismissAttachmentLimitWarning() + } + verify(exactly = 0) { + screenModel.sendAnywayAfterAttachmentLimitWarning() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt new file mode 100644 index 000000000..db4b65674 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt @@ -0,0 +1,98 @@ +package com.android.messaging.ui.conversation.screen.dialogs + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenDeleteDialogsTest : BaseConversationScreenDialogsTest() { + + @Test + fun noDialogState_rendersNoDialogText() { + setDialogsContent(uiState = createDialogUiState()) + + composeTestRule + .onNodeWithText(text(R.string.mms_attachment_limit_reached)) + .assertDoesNotExist() + composeTestRule + .onNodeWithText(deleteConversationTitle()) + .assertDoesNotExist() + } + + @Test + fun deleteConversation_confirmAndDismissForwardCallbacks() { + val screenModel = setDialogsContent( + uiState = createDialogUiState( + isDeleteConversationConfirmationVisible = true, + ), + ) + + composeTestRule + .onNodeWithText(deleteConversationTitle()) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text(R.string.delete_conversation_decline_button)) + .performClick() + composeTestRule + .onNodeWithText(text(R.string.delete_conversation_confirmation_button)) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.dismissDeleteConversationConfirmation() + } + verify(exactly = 1) { + screenModel.confirmDeleteConversation() + } + } + } + + @Test + fun deleteMessages_multiMessageUsesPluralAndForwardsCallbacks() { + val messageIds = persistentSetOf("message-1", "message-2", "message-3") + val screenModel = setDialogsContent( + uiState = createDialogUiState( + deleteConfirmation = ConversationMessageDeleteConfirmationUiState( + messageIds = messageIds, + ), + ), + ) + + composeTestRule + .onNodeWithText( + quantityText( + resourceId = R.plurals.delete_messages_confirmation_dialog_title, + quantity = messageIds.size, + ), + ) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text(R.string.delete_message_confirmation_dialog_text)) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.dismissDeleteMessageConfirmation() + } + verify(exactly = 1) { + screenModel.confirmDeleteSelectedMessages() + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogPlaceholderTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogPlaceholderTest.kt new file mode 100644 index 000000000..df19006f3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogPlaceholderTest.kt @@ -0,0 +1,58 @@ +package com.android.messaging.ui.conversation.screen.dialogs + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenSubjectDialogPlaceholderTest : + BaseConversationScreenDialogsTest() { + + @Test + fun emptySubjectDialog_showsPlaceholderWithoutClearActionAndDismisses() { + val screenModel = setDialogsContent( + uiState = createDialogUiState( + isSubjectDialogVisible = true, + subjectText = "", + ), + ) + + composeTestRule + .onAllNodesWithText(text(R.string.compose_message_view_subject_hint_text)) + .assertCountEquals(expectedSize = 2) + val editableText = composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG) + .fetchSemanticsNode() + .config + .getOrNull(SemanticsProperties.EditableText) + ?.text + assertEquals("", editableText) + composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG) + .assertDoesNotExist() + composeTestRule + .onNodeWithText(cancelText()) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onSubjectDialogDismiss() + } + verify(exactly = 0) { + screenModel.onSubjectDialogConfirm(subjectText = any()) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogTest.kt new file mode 100644 index 000000000..b0adec8b3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenSubjectDialogTest.kt @@ -0,0 +1,68 @@ +package com.android.messaging.ui.conversation.screen.dialogs + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenSubjectDialogTest : BaseConversationScreenDialogsTest() { + + @Test + fun subjectDialog_prefillsFocusesClearsAndConfirmsText() { + val screenModel = setDialogsContent( + uiState = createDialogUiState( + isSubjectDialogVisible = true, + subjectText = INITIAL_SUBJECT, + ), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text(R.string.subject_dialog_title)) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG) + .assertTextEquals(INITIAL_SUBJECT) + .assertIsFocused() + composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG) + .performTextInput(UPDATED_SUBJECT) + composeTestRule + .onNodeWithTag(CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(okText()) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onSubjectDialogConfirm(subjectText = UPDATED_SUBJECT) + } + verify(exactly = 0) { + screenModel.onSubjectDialogDismiss() + } + } + } + + private companion object { + private const val INITIAL_SUBJECT = "Weekend" + private const val UPDATED_SUBJECT = "Dinner plan" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDefaultSmsRoleEffectTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDefaultSmsRoleEffectTest.kt new file mode 100644 index 000000000..415f5bbff --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDefaultSmsRoleEffectTest.kt @@ -0,0 +1,140 @@ +package com.android.messaging.ui.conversation.screen.effects + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.every +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenDefaultSmsRoleEffectTest : + BaseConversationScreenEffectsActionTest() { + + @Test + fun requestDefaultSmsRole_notSendingReplacesExistingSnackbar() { + val expectedMessage = targetContext.getString(R.string.requires_default_sms_app) + setEffectsContent(initialSnackbarMessage = OLD_SNACKBAR_MESSAGE) + waitForSnackbarMessage(message = OLD_SNACKBAR_MESSAGE) + + emitEffect( + ConversationScreenEffect.RequestDefaultSmsRole( + isSending = false, + ), + ) + waitForSnackbarMessage(message = expectedMessage) + + composeTestRule.runOnIdle { + assertEquals( + expectedMessage, + snackbarHostState.currentSnackbarData?.visuals?.message, + ) + } + } + + @Test + fun requestDefaultSmsRole_sendingUsesSendSpecificMessage() { + val expectedMessage = targetContext.getString(R.string.requires_default_sms_app_to_send) + setEffectsContent() + + emitEffect( + ConversationScreenEffect.RequestDefaultSmsRole( + isSending = true, + ), + ) + waitForSnackbarMessage(message = expectedMessage) + + composeTestRule + .onNodeWithText(expectedMessage) + .assertIsDisplayed() + } + + @Test + fun requestDefaultSmsRole_actionPerformedCallsPromptAction() { + val actionText = targetContext.getString(R.string.requires_default_sms_change_button) + setEffectsContent() + + emitEffect( + ConversationScreenEffect.RequestDefaultSmsRole( + isSending = false, + ), + ) + composeTestRule + .onNodeWithText(actionText) + .assertIsDisplayed() + .performClick() + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + screenModel.onDefaultSmsRolePromptActionClick() + } + } + + @Test + fun defaultSmsRoleLauncherResult_forwardsResultCodeToScreenModel() { + setEffectsContent() + + dispatchDefaultSmsRoleResult(resultCode = Activity.RESULT_OK) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onDefaultSmsRoleRequestResult(resultCode = Activity.RESULT_OK) + } + } + } + + @Test + fun launchDefaultSmsRoleRequest_launchesIntent() { + val intent = Intent(DEFAULT_SMS_ROLE_ACTION) + setEffectsContent() + + emitEffect( + ConversationScreenEffect.LaunchDefaultSmsRoleRequest( + intent = intent, + ), + ) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + defaultSmsRoleLauncher.launch(intent, null) + } + verify(exactly = 0) { + screenModel.onDefaultSmsRoleRequestLaunchFailed() + } + } + } + + @Test + fun launchDefaultSmsRoleRequest_whenLauncherThrowsReportsFailure() { + val intent = Intent(DEFAULT_SMS_ROLE_ACTION) + setEffectsContent() + every { + defaultSmsRoleLauncher.launch(intent, null) + } throws ActivityNotFoundException() + + emitEffect( + ConversationScreenEffect.LaunchDefaultSmsRoleRequest( + intent = intent, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + screenModel.onDefaultSmsRoleRequestLaunchFailed() + } + } + + private companion object { + private const val DEFAULT_SMS_ROLE_ACTION = + "com.android.messaging.test.DEFAULT_SMS_ROLE" + private const val OLD_SNACKBAR_MESSAGE = "Existing snackbar" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDraftSentEffectTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDraftSentEffectTest.kt new file mode 100644 index 000000000..5db693322 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenDraftSentEffectTest.kt @@ -0,0 +1,64 @@ +package com.android.messaging.ui.conversation.screen.effects + +import androidx.compose.ui.test.onNodeWithContentDescription +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.util.BuglePrefs +import com.android.messaging.util.MediaUtil +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenDraftSentEffectTest : BaseConversationScreenEffectsActionTest() { + + @Test + fun notifyDraftSent_whenSendSoundEnabledPlaysSoundAndAnnouncesSending() { + val prefs = mockk() + val mediaUtil = mockk(relaxed = true) + mockkStatic(BuglePrefs::class) + mockkStatic(MediaUtil::class) + every { BuglePrefs.getApplicationPrefs() } returns prefs + every { MediaUtil.get() } returns mediaUtil + every { prefs.getBoolean(any(), any()) } returns true + setEffectsContent() + + emitEffect(ConversationScreenEffect.NotifyDraftSent) + + composeTestRule + .onNodeWithContentDescription(targetContext.getString(R.string.sending_message)) + .assertExists() + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + mediaUtil.playSound(any(), R.raw.message_sent, null) + } + } + + @Test + fun notifyDraftSent_whenSendSoundDisabledOnlyAnnouncesSending() { + val prefs = mockk() + val mediaUtil = mockk(relaxed = true) + mockkStatic(BuglePrefs::class) + mockkStatic(MediaUtil::class) + every { BuglePrefs.getApplicationPrefs() } returns prefs + every { MediaUtil.get() } returns mediaUtil + every { prefs.getBoolean(any(), any()) } returns false + setEffectsContent() + + emitEffect(ConversationScreenEffect.NotifyDraftSent) + + composeTestRule + .onNodeWithContentDescription(targetContext.getString(R.string.sending_message)) + .assertExists() + composeTestRule.runOnIdle { + verify(exactly = 0) { + mediaUtil.playSound(any(), any(), any()) + } + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenImmediateEffectsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenImmediateEffectsTest.kt new file mode 100644 index 000000000..ce70dd0ef --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/effects/ConversationScreenImmediateEffectsTest.kt @@ -0,0 +1,232 @@ +package com.android.messaging.ui.conversation.screen.effects + +import android.content.Context +import android.graphics.Point +import android.net.Uri +import android.view.View +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.MessageDetailsDialog +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.util.ContactUtil +import com.android.messaging.util.ContentType +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.verify +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenImmediateEffectsTest : BaseConversationScreenEffectsActionTest() { + + @Test + fun closeConversation_invokesNavigationCallback() { + var navigationCount = 0 + setEffectsContent( + onNavigateBack = { + navigationCount += 1 + }, + ) + + emitEffect(ConversationScreenEffect.CloseConversation) + + composeTestRule.runOnIdle { + assertEquals(1, navigationCount) + } + } + + @Test + fun openExternalUri_forwardsToUiIntents() { + val uiIntents = mockk(relaxed = true) + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + setEffectsContent() + + emitEffect(ConversationScreenEffect.OpenExternalUri(uri = EXTERNAL_URL)) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + uiIntents.launchBrowserForUrl(any(), EXTERNAL_URL) + } + } + + @Test + fun placePhoneCall_forwardsPhoneNumberAndZeroOrigin() { + val uiIntents = mockk(relaxed = true) + val pointSlot = slot() + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + setEffectsContent() + + emitEffect(ConversationScreenEffect.PlacePhoneCall(phoneNumber = PHONE_NUMBER)) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + uiIntents.launchPhoneCallActivity(any(), PHONE_NUMBER, capture(pointSlot)) + } + assertEquals(0, pointSlot.captured.x) + assertEquals(0, pointSlot.captured.y) + } + + @Test + fun launchAddContactFlow_forwardsDestinationToUiIntents() { + val uiIntents = mockk(relaxed = true) + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + setEffectsContent() + + emitEffect( + ConversationScreenEffect.LaunchAddContactFlow( + destination = CONTACT_DESTINATION, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + uiIntents.launchAddContactActivity(any(), CONTACT_DESTINATION) + } + } + + @Test + fun launchForwardMessage_forwardsMessageToUiIntents() { + val uiIntents = mockk(relaxed = true) + val message = mockk() + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + setEffectsContent() + + emitEffect( + ConversationScreenEffect.LaunchForwardMessage( + message = message, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + uiIntents.launchForwardMessageActivity(any(), message) + } + } + + @Test + fun openVCardAttachmentPreview_forwardsUriToUiIntents() { + val uiIntents = mockk(relaxed = true) + val contentUri = "content://attachments/contact-card" + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + setEffectsContent() + + emitEffect( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = ContentType.TEXT_VCARD, + contentUri = contentUri, + imageCollectionUri = null, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + uiIntents.launchVCardDetailActivity(any(), Uri.parse(contentUri)) + } + } + + @Test + fun openVideoAttachmentPreview_forwardsUriToUiIntents() { + val uiIntents = mockk(relaxed = true) + val contentUri = "content://attachments/video" + mockkStatic(UIIntents::class) + every { UIIntents.get() } returns uiIntents + setEffectsContent() + + emitEffect( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = ContentType.VIDEO_MP4, + contentUri = contentUri, + imageCollectionUri = null, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + uiIntents.launchFullScreenVideoViewer(any(), Uri.parse(contentUri)) + } + } + + @Test + fun showOrAddParticipantContact_forwardsContactDetails() { + val avatarUri = Uri.parse("content://avatar/1") + mockkStatic(ContactUtil::class) + every { + ContactUtil.showOrAddContact( + any(), + 42L, + "lookup-key", + avatarUri, + CONTACT_DESTINATION, + ) + } just runs + setEffectsContent() + + emitEffect( + ConversationScreenEffect.ShowOrAddParticipantContact( + contactId = 42L, + contactLookupKey = "lookup-key", + avatarUri = avatarUri, + normalizedDestination = CONTACT_DESTINATION, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + ContactUtil.showOrAddContact( + any(), + 42L, + "lookup-key", + avatarUri, + CONTACT_DESTINATION, + ) + } + } + + @Test + fun showMessageDetails_forwardsMessageAndParticipants() { + val message = mockk() + val participants = mockk() + val selfParticipant = mockk() + mockkStatic(MessageDetailsDialog::class) + every { + MessageDetailsDialog.show( + any(), + message, + participants, + selfParticipant, + ) + } just runs + setEffectsContent() + + emitEffect( + ConversationScreenEffect.ShowMessageDetails( + message = message, + participants = participants, + selfParticipant = selfParticipant, + ), + ) + + verify(timeout = TEST_WAIT_TIMEOUT_MILLIS, exactly = 1) { + MessageDetailsDialog.show( + any(), + message, + participants, + selfParticipant, + ) + } + } + + private companion object { + private const val EXTERNAL_URL = "https://example.com/message" + private const val PHONE_NUMBER = "+15551234567" + private const val CONTACT_DESTINATION = "+15557654321" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteCallbackTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteCallbackTest.kt new file mode 100644 index 000000000..bcc047f8d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteCallbackTest.kt @@ -0,0 +1,348 @@ +package com.android.messaging.ui.conversation.screen.route + +import android.Manifest +import android.net.Uri +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.messaging.ui.conversation.mediapicker.model.ConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.screen.AudioRecordingStartMode +import com.android.messaging.ui.conversation.screen.BaseConversationScreenTest +import com.android.messaging.ui.conversation.screen.ConversationScreenModel +import com.android.messaging.ui.conversation.screen.rememberAudioRecordingStartRequest +import com.android.messaging.ui.conversation.screen.rememberOpenContactPickerCallback +import com.android.messaging.ui.core.AppTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenRouteCallbackTest : BaseConversationScreenTest() { + + @Test + fun audioRecordingStart_whenPermissionGrantedStartsRequestedMode() { + val screenModel = createScreenModel().model + every { screenModel.tryStartAddingAttachment() } returns true + setAudioRecordingStartContent( + screenModel = screenModel, + audioPermissionGranted = true, + ) + + composeTestRule + .onNodeWithTag(START_AUDIO_UNLOCKED_BUTTON_TAG) + .performClick() + composeTestRule + .onNodeWithTag(START_AUDIO_LOCKED_BUTTON_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 2) { + screenModel.tryStartAddingAttachment() + } + verify(exactly = 1) { + screenModel.onAudioRecordingStart() + } + verify(exactly = 1) { + screenModel.onLockedAudioRecordingStart() + } + } + } + + @Test + fun audioRecordingStart_whenAttachmentCannotStartDoesNotStartRecording() { + val screenModel = createScreenModel().model + every { screenModel.tryStartAddingAttachment() } returns false + setAudioRecordingStartContent( + screenModel = screenModel, + audioPermissionGranted = true, + ) + + composeTestRule + .onNodeWithTag(START_AUDIO_UNLOCKED_BUTTON_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.tryStartAddingAttachment() + } + verify(exactly = 0) { + screenModel.onAudioRecordingStart() + } + verify(exactly = 0) { + screenModel.onLockedAudioRecordingStart() + } + } + } + + @Test + fun audioRecordingStart_whenPermissionMissingRequestsPermissionBeforeStartingRecording() { + val screenModel = createScreenModel().model + val permissionLauncher = mockk>(relaxed = true) + val permissionCallbackSlot = slot>() + val registry = mockk() + every { screenModel.tryStartAddingAttachment() } returns true + every { + registry.register( + any(), + any>(), + capture(permissionCallbackSlot), + ) + } returns permissionLauncher + setAudioRecordingStartContent( + screenModel = screenModel, + audioPermissionGranted = false, + activityResultRegistryOwner = createActivityResultRegistryOwner( + activityResultRegistry = registry, + ), + ) + + composeTestRule + .onNodeWithTag(START_AUDIO_UNLOCKED_BUTTON_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO, null) + } + verify(exactly = 0) { + screenModel.onAudioRecordingStart() + } + permissionCallbackSlot.captured.onActivityResult(true) + } + + composeTestRule + .onNodeWithTag(START_AUDIO_UNLOCKED_BUTTON_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 2) { + screenModel.tryStartAddingAttachment() + } + verify(exactly = 1) { + screenModel.onAudioRecordingStart() + } + } + } + + @Test + fun contactPicker_whenAttachmentCannotStartDoesNotLaunchPicker() { + val screenModel = createScreenModel().model + val contactLauncher = mockk>(relaxed = true) + val contactCallbackSlot = slot>() + val registry = mockk() + every { screenModel.tryStartAddingAttachment() } returns false + every { + registry.register( + any(), + any>(), + capture(contactCallbackSlot), + ) + } returns contactLauncher + setContactPickerContent( + screenModel = screenModel, + activityResultRegistryOwner = createActivityResultRegistryOwner( + activityResultRegistry = registry, + ), + ) + + composeTestRule + .onNodeWithTag(OPEN_CONTACT_PICKER_BUTTON_TAG) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.tryStartAddingAttachment() + } + verify(exactly = 0) { + contactLauncher.launch(any(), any()) + } + verify(exactly = 0) { + screenModel.onContactCardPicked(any()) + } + } + } + + @Test + fun contactPicker_whenContactSelectedLaunchesPickerAndForwardsUri() { + val screenModel = createScreenModel().model + val contactLauncher = mockk>(relaxed = true) + val contactCallbackSlot = slot>() + val registry = mockk() + every { screenModel.tryStartAddingAttachment() } returns true + every { + registry.register( + any(), + any>(), + capture(contactCallbackSlot), + ) + } returns contactLauncher + setContactPickerContent( + screenModel = screenModel, + activityResultRegistryOwner = createActivityResultRegistryOwner( + activityResultRegistry = registry, + ), + ) + + composeTestRule + .onNodeWithTag(OPEN_CONTACT_PICKER_BUTTON_TAG) + .performClick() + composeTestRule.runOnIdle { + contactCallbackSlot.captured.onActivityResult(Uri.parse(CONTACT_URI)) + } + + composeTestRule.runOnIdle { + verify(exactly = 1) { + contactLauncher.launch(null, null) + } + verify(exactly = 1) { + screenModel.onContactCardPicked(contactUri = CONTACT_URI) + } + } + } + + @Test + fun contactPicker_whenPickerCanceledForwardsNullUri() { + val screenModel = createScreenModel().model + val contactLauncher = mockk>(relaxed = true) + val contactCallbackSlot = slot>() + val registry = mockk() + every { screenModel.tryStartAddingAttachment() } returns true + every { + registry.register( + any(), + any>(), + capture(contactCallbackSlot), + ) + } returns contactLauncher + setContactPickerContent( + screenModel = screenModel, + activityResultRegistryOwner = createActivityResultRegistryOwner( + activityResultRegistry = registry, + ), + ) + + composeTestRule + .onNodeWithTag(OPEN_CONTACT_PICKER_BUTTON_TAG) + .performClick() + composeTestRule.runOnIdle { + contactCallbackSlot.captured.onActivityResult(null) + } + + composeTestRule.runOnIdle { + verify(exactly = 1) { + contactLauncher.launch(null, null) + } + verify(exactly = 1) { + screenModel.onContactCardPicked(contactUri = null) + } + } + } + + private fun setAudioRecordingStartContent( + screenModel: ConversationScreenModel, + audioPermissionGranted: Boolean, + activityResultRegistryOwner: ActivityResultRegistryOwner? = null, + ) { + composeTestRule.setContent { + val content: @Composable () -> Unit = { + val context = LocalContext.current + val permissionState = remember { + ConversationMediaPickerPermissionState(context = context).apply { + this.audioPermissionGranted = audioPermissionGranted + } + } + val startRequest = rememberAudioRecordingStartRequest( + screenModel = screenModel, + permissionState = permissionState, + ) + + Column { + Button( + modifier = Modifier.testTag(tag = START_AUDIO_UNLOCKED_BUTTON_TAG), + onClick = { + startRequest(AudioRecordingStartMode.Unlocked) + }, + ) { + Text(text = "Unlocked") + } + Button( + modifier = Modifier.testTag(tag = START_AUDIO_LOCKED_BUTTON_TAG), + onClick = { + startRequest(AudioRecordingStartMode.Locked) + }, + ) { + Text(text = "Locked") + } + } + } + + when (activityResultRegistryOwner) { + null -> AppTheme(content = content) + else -> { + CompositionLocalProvider( + LocalActivityResultRegistryOwner provides activityResultRegistryOwner, + ) { + AppTheme(content = content) + } + } + } + } + } + + private fun setContactPickerContent( + screenModel: ConversationScreenModel, + activityResultRegistryOwner: ActivityResultRegistryOwner, + ) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalActivityResultRegistryOwner provides activityResultRegistryOwner, + ) { + AppTheme { + val openContactPicker = rememberOpenContactPickerCallback( + screenModel = screenModel, + ) + + Button( + modifier = Modifier.testTag(tag = OPEN_CONTACT_PICKER_BUTTON_TAG), + onClick = openContactPicker, + ) { + Text(text = "Contact") + } + } + } + } + } + + private fun createActivityResultRegistryOwner( + activityResultRegistry: ActivityResultRegistry, + ): ActivityResultRegistryOwner { + val owner = mockk() + every { owner.activityResultRegistry } returns activityResultRegistry + return owner + } + + private companion object { + private const val START_AUDIO_UNLOCKED_BUTTON_TAG = "start_audio_unlocked" + private const val START_AUDIO_LOCKED_BUTTON_TAG = "start_audio_locked" + private const val OPEN_CONTACT_PICKER_BUTTON_TAG = "open_contact_picker" + private const val CONTACT_URI = "content://contacts/people/11" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteEffectsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteEffectsTest.kt new file mode 100644 index 000000000..0af64752e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/route/ConversationScreenRouteEffectsTest.kt @@ -0,0 +1,343 @@ +package com.android.messaging.ui.conversation.screen.route + +import androidx.lifecycle.Lifecycle +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.TestLifecycleOwner +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.screen.BaseConversationScreenTest +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenRouteEffectsTest : BaseConversationScreenTest() { + + @Test + fun pendingPayloads_withoutConversationIdAreIgnored() { + val screenModel = createScreenModel() + var draftConsumedCount = 0 + var selfParticipantConsumedCount = 0 + var attachmentConsumedCount = 0 + val pendingDraft = ConversationDraft( + messageText = "Pending", + ) + val pendingAttachment = ConversationEntryStartupAttachment( + contentType = "image/png", + contentUri = "content://media/image/10", + ) + + setContent( + screenModel = screenModel.model, + conversationId = { null }, + launchGeneration = { 3 }, + pendingDraft = pendingDraft, + pendingSelfParticipantId = SELF_PARTICIPANT_ID, + pendingStartupAttachment = pendingAttachment, + onPendingDraftConsumed = { + draftConsumedCount += 1 + }, + onPendingSelfParticipantIdConsumed = { + selfParticipantConsumedCount += 1 + }, + onPendingStartupAttachmentConsumed = { + attachmentConsumedCount += 1 + }, + ) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onConversationIdChanged(conversationId = null) + } + verify(exactly = 0) { + screenModel.model.onSeedDraft(any(), any()) + } + verify(exactly = 0) { + screenModel.model.onSimSelected(any()) + } + verify(exactly = 0) { + screenModel.model.onOpenStartupAttachment(any(), any()) + } + assertEquals(0, draftConsumedCount) + assertEquals(0, selfParticipantConsumedCount) + assertEquals(0, attachmentConsumedCount) + } + } + + @Test + fun pendingPayloads_withoutLaunchGenerationAreIgnored() { + val screenModel = createScreenModel() + var draftConsumedCount = 0 + var selfParticipantConsumedCount = 0 + var attachmentConsumedCount = 0 + val pendingDraft = ConversationDraft( + messageText = "Pending", + ) + val pendingAttachment = ConversationEntryStartupAttachment( + contentType = "image/png", + contentUri = "content://media/image/10", + ) + + setContent( + screenModel = screenModel.model, + launchGeneration = { null }, + pendingDraft = pendingDraft, + pendingSelfParticipantId = SELF_PARTICIPANT_ID, + pendingStartupAttachment = pendingAttachment, + onPendingDraftConsumed = { + draftConsumedCount += 1 + }, + onPendingSelfParticipantIdConsumed = { + selfParticipantConsumedCount += 1 + }, + onPendingStartupAttachmentConsumed = { + attachmentConsumedCount += 1 + }, + ) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + screenModel.model.onSeedDraft(any(), any()) + } + verify(exactly = 0) { + screenModel.model.onSimSelected(any()) + } + verify(exactly = 0) { + screenModel.model.onOpenStartupAttachment(any(), any()) + } + assertEquals(0, draftConsumedCount) + assertEquals(0, selfParticipantConsumedCount) + assertEquals(0, attachmentConsumedCount) + } + } + + @Test + fun pendingDraft_withConversationAndLaunchGenerationSeedsDraftAndNotifiesConsumption() { + val screenModel = createScreenModel() + var draftConsumedCount = 0 + val pendingDraft = ConversationDraft( + messageText = "Pending body", + subjectText = "Subject", + ) + + setContent( + screenModel = screenModel.model, + pendingDraft = pendingDraft, + onPendingDraftConsumed = { + draftConsumedCount += 1 + }, + ) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onSeedDraft( + conversationId = CONVERSATION_ID, + draft = pendingDraft, + ) + } + assertEquals(1, draftConsumedCount) + } + } + + @Test + fun pendingSelfParticipantId_selectsSimAndNotifiesConsumption() { + val screenModel = createScreenModel() + var selfParticipantConsumedCount = 0 + + setContent( + screenModel = screenModel.model, + pendingSelfParticipantId = SELF_PARTICIPANT_ID, + onPendingSelfParticipantIdConsumed = { + selfParticipantConsumedCount += 1 + }, + ) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onSimSelected(selfParticipantId = SELF_PARTICIPANT_ID) + } + assertEquals(1, selfParticipantConsumedCount) + } + } + + @Test + fun pendingStartupAttachment_withConversationAndLaunchGenerationOpensAndNotifiesConsumption() { + val screenModel = createScreenModel() + var attachmentConsumedCount = 0 + val pendingAttachment = ConversationEntryStartupAttachment( + contentType = "image/jpeg", + contentUri = "content://media/image/22", + ) + + setContent( + screenModel = screenModel.model, + pendingStartupAttachment = pendingAttachment, + onPendingStartupAttachmentConsumed = { + attachmentConsumedCount += 1 + }, + ) + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onOpenStartupAttachment( + conversationId = CONVERSATION_ID, + startupAttachment = pendingAttachment, + ) + } + assertEquals(1, attachmentConsumedCount) + } + } + + @Test + fun foregroundAndBackgroundLifecycleEventsForwardToScreenModel() { + val screenModel = createScreenModel() + lateinit var lifecycleOwner: TestLifecycleOwner + + composeTestRule.runOnIdle { + lifecycleOwner = TestLifecycleOwner( + initialState = Lifecycle.State.CREATED, + ) + } + + setContent( + screenModel = screenModel.model, + lifecycleOwner = lifecycleOwner, + cancelIncomingNotification = false, + ) + composeTestRule.runOnIdle { + lifecycleOwner.moveTo(state = Lifecycle.State.RESUMED) + } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + lifecycleOwner.moveTo(state = Lifecycle.State.STARTED) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onScreenForegrounded(cancelNotification = false) + } + verify(exactly = 1) { + screenModel.model.onScreenBackgrounded() + } + } + } + + @Test + fun stoppingWithoutRecordingPersistsDraftWithoutCancellingRecording() { + val screenModel = createScreenModel() + lateinit var lifecycleOwner: TestLifecycleOwner + + composeTestRule.runOnIdle { + lifecycleOwner = TestLifecycleOwner( + initialState = Lifecycle.State.RESUMED, + ) + } + + setContent( + screenModel = screenModel.model, + lifecycleOwner = lifecycleOwner, + ) + composeTestRule.runOnIdle { + lifecycleOwner.moveTo(state = Lifecycle.State.CREATED) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + screenModel.model.onAudioRecordingCancel() + } + verify(exactly = 1) { + screenModel.model.persistDraft() + } + } + } + + @Test + fun stoppingWhileRecordingCancelsRecordingAndPersistsDraft() { + val screenModel = createScreenModel() + lateinit var lifecycleOwner: TestLifecycleOwner + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 1, + latestMessageId = "message-1", + latestMessageIncoming = false, + ), + ).let { uiState -> + uiState.copy( + composer = uiState.composer.copy( + audioRecording = ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + ), + ), + ) + } + + composeTestRule.runOnIdle { + lifecycleOwner = TestLifecycleOwner( + initialState = Lifecycle.State.RESUMED, + ) + } + + setContent( + screenModel = screenModel.model, + lifecycleOwner = lifecycleOwner, + ) + composeTestRule.runOnIdle { + lifecycleOwner.moveTo(state = Lifecycle.State.CREATED) + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onAudioRecordingCancel() + } + verify(exactly = 1) { + screenModel.model.persistDraft() + } + } + } + + @Test + fun backPressInSelectionModeDismissesMessageSelection() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 1, + latestMessageId = "message-1", + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf("message-1"), + ), + ) + + setContent(screenModel = screenModel.model) + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { + composeTestRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.dismissMessageSelection() + } + } + } + + private companion object { + private const val SELF_PARTICIPANT_ID = "self-2" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/selection/ConversationSelectionTopAppBarResidualActionsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/selection/ConversationSelectionTopAppBarResidualActionsTest.kt new file mode 100644 index 000000000..c4cb86ec9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/selection/ConversationSelectionTopAppBarResidualActionsTest.kt @@ -0,0 +1,134 @@ +package com.android.messaging.ui.conversation.screen.selection + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.messaging.ui.conversation.CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageSelectionActionButtonTestTag +import com.android.messaging.ui.conversation.screen.BaseConversationScreenTest +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import io.mockk.verify +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationSelectionTopAppBarResidualActionsTest : BaseConversationScreenTest() { + + @Test + fun primaryDownloadAndResendActions_areRenderedAndForwardClicks() { + val screenModel = createScreenModel() + setSelectionContent( + screenModel = screenModel, + actions = listOf( + ConversationMessageSelectionAction.Download, + ConversationMessageSelectionAction.Resend, + ), + ) + + clickSelectionAction(action = ConversationMessageSelectionAction.Download) + clickSelectionAction(action = ConversationMessageSelectionAction.Resend) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Download, + ) + } + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Resend, + ) + } + } + } + + @Test + fun overflowShareForwardAndDetailsActions_dismissMenuAndForwardClicks() { + val screenModel = createScreenModel() + setSelectionContent( + screenModel = screenModel, + actions = listOf( + ConversationMessageSelectionAction.Share, + ConversationMessageSelectionAction.Forward, + ConversationMessageSelectionAction.Details, + ), + ) + + clickOverflowSelectionAction(action = ConversationMessageSelectionAction.Share) + assertOverflowActionHidden(action = ConversationMessageSelectionAction.Share) + clickOverflowSelectionAction(action = ConversationMessageSelectionAction.Forward) + clickOverflowSelectionAction(action = ConversationMessageSelectionAction.Details) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Share, + ) + } + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Forward, + ) + } + verify(exactly = 1) { + screenModel.model.onMessageSelectionActionClick( + action = ConversationMessageSelectionAction.Details, + ) + } + } + } + + private fun setSelectionContent( + screenModel: ScreenModelHandle, + actions: List, + ) { + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 1, + latestMessageId = MESSAGE_ID, + latestMessageIncoming = false, + ), + selection = ConversationMessageSelectionUiState( + selectedMessageIds = persistentSetOf(MESSAGE_ID), + availableActions = persistentSetOf(*actions.toTypedArray()), + ), + ) + + setContent(screenModel = screenModel.model) + } + + private fun clickSelectionAction(action: ConversationMessageSelectionAction) { + composeTestRule + .onNodeWithTag(selectionActionTag(action = action)) + .assertIsDisplayed() + .performClick() + } + + private fun clickOverflowSelectionAction(action: ConversationMessageSelectionAction) { + composeTestRule + .onNodeWithTag(CONVERSATION_SELECTION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + clickSelectionAction(action = action) + } + + @Suppress("SameParameterValue") + private fun assertOverflowActionHidden(action: ConversationMessageSelectionAction) { + composeTestRule + .onAllNodesWithTag(selectionActionTag(action = action)) + .assertCountEquals(expectedSize = 0) + } + + private fun selectionActionTag(action: ConversationMessageSelectionAction): String { + return conversationMessageSelectionActionButtonTestTag(action = action.name) + } + + private companion object { + private const val MESSAGE_ID = "message-1" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSendButtonSimSelectorIntegrationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSendButtonSimSelectorIntegrationTest.kt new file mode 100644 index 000000000..a75ff29ca --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSendButtonSimSelectorIntegrationTest.kt @@ -0,0 +1,81 @@ +package com.android.messaging.ui.conversation.screen.simselector + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.conversationSimSelectorItemTestTag +import com.android.messaging.ui.conversation.testAttSubscription +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenSendButtonSimSelectorIntegrationTest : + BaseConversationScreenSimSelectorIntegrationTest() { + + @Test + fun sendLongPress_whenSimSelectorUnavailableDoesNotOpenSheet() { + val screenModel = createScreenModel() + setSimSelectorContent( + screenModel = screenModel, + simSelector = createSingleSimSelector(), + ) + + longClickSendButton() + + composeTestRule + .onAllNodesWithTag(CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } + + @Test + fun sendLongPress_whenSimSelectorAvailableOpensSheetAndSelectingSimDismissesIt() { + val screenModel = createScreenModel() + setSimSelectorContent( + screenModel = screenModel, + simSelector = createMultiSimSelector(), + ) + + longClickSendButton() + waitForSheetNodeCount(expectedCount = 1) + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag( + conversationSimSelectorItemTestTag( + selfParticipantId = testAttSubscription.selfParticipantId, + ), + ) + .performClick() + waitForSheetNodeCount(expectedCount = 0) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onSimSelected( + selfParticipantId = testAttSubscription.selfParticipantId, + ) + } + } + } + + private fun longClickSendButton() { + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_SEND_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performTouchInput { + longClick(position = center) + } + composeTestRule.waitForIdle() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSimSelectorIntegrationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSimSelectorIntegrationTest.kt new file mode 100644 index 000000000..56fba8ca2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/simselector/ConversationScreenSimSelectorIntegrationTest.kt @@ -0,0 +1,84 @@ +package com.android.messaging.ui.conversation.screen.simselector + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.conversationSimSelectorItemTestTag +import com.android.messaging.ui.conversation.testAttSubscription +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenSimSelectorIntegrationTest : + BaseConversationScreenSimSelectorIntegrationTest() { + + @Test + fun topBarSimSelector_whenAvailableOpensSheetAndSelectingSimDismissesIt() { + val screenModel = createScreenModel() + setSimSelectorContent( + screenModel = screenModel, + simSelector = createMultiSimSelector(), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG) + .assertIsDisplayed() + .performClick() + waitForSheetNodeCount(expectedCount = 1) + + composeTestRule + .onNodeWithTag( + conversationSimSelectorItemTestTag( + selfParticipantId = testAttSubscription.selfParticipantId, + ), + ) + .performClick() + waitForSheetNodeCount(expectedCount = 0) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.model.onSimSelected( + selfParticipantId = testAttSubscription.selfParticipantId, + ) + } + } + } + + @Test + fun openSimSelector_whenSelectorBecomesUnavailableDismissesSheet() { + val screenModel = createScreenModel() + setSimSelectorContent( + screenModel = screenModel, + simSelector = createMultiSimSelector(), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG) + .assertIsDisplayed() + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG) + .assertIsDisplayed() + .performClick() + waitForSheetNodeCount(expectedCount = 1) + + screenModel.scaffoldUiStateFlow.value = screenModel.scaffoldUiStateFlow.value.let { + it.copy( + composer = it.composer.copy( + simSelector = createSingleSimSelector(), + ), + ) + } + + waitForSheetNodeCount(expectedCount = 0) + } +} From c2eba0bf54be6889dd8c2bb1bb7c21eae51e1883 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:16:21 +0300 Subject: [PATCH 23/38] Add focused Compose instrumentation tests --- ...ConversationActivityRecipientPickerTest.kt | 50 +++++++++++ .../ui/ConversationComposeBarLayoutTest.kt | 80 +++++++++++++++++ ...onInlineAudioAttachmentRowAnimationTest.kt | 50 +++++++++++ .../ConversationMessageTextLinkRoutingTest.kt | 87 +++++++++++++++++++ ...rsationScreenMediaPickerInteractionTest.kt | 49 +++++++++++ 5 files changed, 316 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationActivityRecipientPickerTest.kt create mode 100644 app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt create mode 100644 app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowAnimationTest.kt create mode 100644 app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkRoutingTest.kt create mode 100644 app/src/androidTest/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenMediaPickerInteractionTest.kt diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationActivityRecipientPickerTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationActivityRecipientPickerTest.kt new file mode 100644 index 000000000..8dcf563f2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/ConversationActivityRecipientPickerTest.kt @@ -0,0 +1,50 @@ +package com.android.messaging.ui.conversation + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ConversationActivityRecipientPickerTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun createGroupAction_keepsUserOnNewChatScreenAndShowsInlineSelectionMode() { + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG) + .performClick() + + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_TOP_APP_BAR_TITLE_TEST_TAG) + .assertTextEquals( + targetContext.getString(R.string.conversation_new_group), + ) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithTag(testTag = NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG) + .assertCountEquals(expectedSize = 0) + + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_NAVIGATE_BACK_BUTTON_TEST_TAG) + .performClick() + + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_TOP_APP_BAR_TITLE_TEST_TAG) + .assertTextEquals( + targetContext.getString(R.string.start_new_conversation), + ) + .assertIsDisplayed() + } +} diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt new file mode 100644 index 000000000..85e25158b --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt @@ -0,0 +1,80 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.core.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ConversationComposeBarLayoutTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun singleLineInput_keepsTextFieldAndSendButtonHeightsEqual() { + composeTestRule.setContent { + AppTheme { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + ConversationComposeBar( + audioRecording = ConversationAudioRecordingUiState(), + messageText = "Hello", + subjectText = "", + sendProtocol = ConversationDraftSendProtocol.SMS, + segmentCounter = null, + isMessageFieldEnabled = true, + isAttachmentActionEnabled = false, + isRecordActionEnabled = true, + isSendActionEnabled = true, + shouldShowRecordAction = false, + onContactAttachClick = {}, + onMediaPickerClick = {}, + onLockedAudioRecordingStartRequest = {}, + onMessageTextChange = {}, + onAudioRecordingStartRequest = {}, + onAudioRecordingFinish = {}, + onAudioRecordingLock = { false }, + onAudioRecordingCancel = {}, + onSendClick = {}, + onSendActionLongClick = {}, + onSubjectChipClick = {}, + onSubjectChipClear = {}, + ) + } + } + } + + val textFieldBounds = composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .getUnclippedBoundsInRoot() + + val sendButtonBounds = composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .getUnclippedBoundsInRoot() + + val textFieldHeight = textFieldBounds.bottom - textFieldBounds.top + val sendButtonHeight = sendButtonBounds.bottom - sendButtonBounds.top + + assertEquals( + textFieldHeight.value, + sendButtonHeight.value, + 0.5f, + ) + } +} diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowAnimationTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowAnimationTest.kt new file mode 100644 index 000000000..f51fbcf23 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowAnimationTest.kt @@ -0,0 +1,50 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ConversationInlineAudioAttachmentRowAnimationTest : + BaseConversationInlineAudioAttachmentRowTest() { + + @Test + fun progressIndicator_animatesInAndOutAsPlaybackStateChanges() { + composeTestRule.mainClock.autoAdvance = false + + var isPlaying by mutableStateOf(value = false) + + setContent( + isPlaying = { isPlaying }, + progress = { 0f }, + ) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG) + .assertCountEquals(expectedSize = 0) + + composeTestRule.runOnIdle { + isPlaying = true + } + composeTestRule.mainClock.advanceTimeBy(milliseconds = 500L) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG) + .assertCountEquals(expectedSize = 1) + + composeTestRule.runOnIdle { + isPlaying = false + } + composeTestRule.mainClock.advanceTimeBy(milliseconds = 500L) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } +} diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkRoutingTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkRoutingTest.kt new file mode 100644 index 000000000..45222b21f --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkRoutingTest.kt @@ -0,0 +1,87 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import io.mockk.verify +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ConversationMessageTextLinkRoutingTest : BaseConversationMessageRenderingTest() { + + @Test + fun externalLinkClick_selectionModeForwardsMessageClickOnly() { + setConversationMessageContent( + message = message(text = LINK_TEXT), + isSelectionMode = true, + ) + + awaitLinkAnnotated(text = LINK_TEXT) + + composeTestRule + .onNodeWithText(text = LINK_TEXT, useUnmergedTree = true) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageClick.invoke() + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun externalLinkClick_resendableMessageForwardsResendOnly() { + setConversationMessageContent( + message = message( + text = LINK_TEXT, + status = ConversationMessageUiModel.Status.Outgoing.Failed, + canResendMessage = true, + ), + ) + + awaitLinkAnnotated(text = LINK_TEXT) + + composeTestRule + .onNodeWithText(text = LINK_TEXT, useUnmergedTree = true) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onResendClick.invoke() + } + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + verify(exactly = 0) { + onMessageClick.invoke() + } + } + } + + @Suppress("SameParameterValue") + private fun awaitLinkAnnotated(text: String) { + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + composeTestRule + .onAllNodesWithText(text = text, useUnmergedTree = true) + .fetchSemanticsNodes() + .any { node -> + node.config + .getOrNull(SemanticsProperties.Text) + ?.any { it.hasLinkAnnotations(start = 0, end = it.length) } == true + } + } + } + + private companion object { + private const val LINK_TEXT = "https://example.com" + } +} diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenMediaPickerInteractionTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenMediaPickerInteractionTest.kt new file mode 100644 index 000000000..a8870e207 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenMediaPickerInteractionTest.kt @@ -0,0 +1,49 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ConversationScreenMediaPickerInteractionTest : BaseConversationScreenTest() { + + @Test + fun openingMediaPicker_hidesComposerAndShowsOverlay() { + val screenModel = createScreenModel() + screenModel.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + ) + + setContent(screenModel = screenModel.model) + + composeTestRule + .onNodeWithTag( + testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG, + useUnmergedTree = true, + ) + .performClick() + composeTestRule + .onNodeWithTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG) + .performClick() + composeTestRule.waitForIdle() + + composeTestRule + .onAllNodesWithTag(CONVERSATION_COMPOSE_BAR_TEST_TAG) + .assertCountEquals(expectedSize = 0) + composeTestRule + .onAllNodesWithTag(CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG) + .assertCountEquals(expectedSize = 1) + } +} From f7f5bae38e3a4b2f2153d761a31b417e8d34b111 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:19:16 +0300 Subject: [PATCH 24/38] Add JaCoCo rule lists --- app/jacoco-rules/README.md | 15 ++++ .../common/excluded-class-patterns.txt | 19 ++++++ .../instrumented/excluded-class-patterns.txt | 12 ++++ .../instrumented/included-class-patterns.txt | 5 ++ .../measured-composable-sources.txt | 2 + .../measured-non-composable-sources.txt | 2 + .../unit/excluded-class-patterns.txt | 51 ++++++++++++++ .../unit/included-class-patterns.txt | 10 +++ .../unit/measured-composable-sources.txt | 68 +++++++++++++++++++ 9 files changed, 184 insertions(+) create mode 100644 app/jacoco-rules/README.md create mode 100644 app/jacoco-rules/common/excluded-class-patterns.txt create mode 100644 app/jacoco-rules/instrumented/excluded-class-patterns.txt create mode 100644 app/jacoco-rules/instrumented/included-class-patterns.txt create mode 100644 app/jacoco-rules/instrumented/measured-composable-sources.txt create mode 100644 app/jacoco-rules/instrumented/measured-non-composable-sources.txt create mode 100644 app/jacoco-rules/unit/excluded-class-patterns.txt create mode 100644 app/jacoco-rules/unit/included-class-patterns.txt create mode 100644 app/jacoco-rules/unit/measured-composable-sources.txt diff --git a/app/jacoco-rules/README.md b/app/jacoco-rules/README.md new file mode 100644 index 000000000..92c7c09d5 --- /dev/null +++ b/app/jacoco-rules/README.md @@ -0,0 +1,15 @@ +# JaCoCo Rules + +Human-maintained coverage policy lives in these text files. The Gradle script +loads them at configuration time and fails fast on missing files, duplicate +rules, backslash separators, or source/class-pattern mixups. + +Format: + +- One rule per line. +- Blank lines and lines starting with `#` are ignored. +- `*-class-patterns.txt` files use Gradle `fileTree` patterns against compiled class directories. +- `*-sources.txt` files are paths relative to `src/com/android/messaging/ui`. + +The script still owns generated synthetic-class patterns and source-to-class pattern derivation. +Keep only human-maintained coverage policy here. diff --git a/app/jacoco-rules/common/excluded-class-patterns.txt b/app/jacoco-rules/common/excluded-class-patterns.txt new file mode 100644 index 000000000..8d82fe879 --- /dev/null +++ b/app/jacoco-rules/common/excluded-class-patterns.txt @@ -0,0 +1,19 @@ +# Common class-directory exclude patterns for unit and instrumented JaCoCo tracks. + +# Dependency injection generated classes. +**/Dagger* +**/Hilt_* +**/_Factory* +**/_GeneratedInjector* +**/_HiltModules* +**/_MembersInjector* + +# Compose and data-holder noise. +**/*ComposableSingletons* +**/model/** + +# Kotlin compiler-generated classes. +**/*$DefaultImpls* +**/*$invokeSuspend$* +**/*$serializer +**/*$$inlined$* diff --git a/app/jacoco-rules/instrumented/excluded-class-patterns.txt b/app/jacoco-rules/instrumented/excluded-class-patterns.txt new file mode 100644 index 000000000..d73a7619a --- /dev/null +++ b/app/jacoco-rules/instrumented/excluded-class-patterns.txt @@ -0,0 +1,12 @@ +# Instrumented-test-only class-directory exclude patterns. +**/ui/conversation/mediapicker/ConversationCaptureMode.class +**/ui/conversation/mediapicker/ConversationMediaPickerSavedState.class +**/ui/conversation/mediapicker/ConversationMediaPickerSavedState$*.class +**/ui/conversation/mediapicker/ConversationMediaPickerState.class +**/ui/conversation/mediapicker/ConversationMediaPickerState$*.class +**/ui/conversation/mediapicker/camera/ConversationCameraController.class +**/ui/conversation/mediapicker/camera/ConversationCameraControllerImpl*.class +**/ui/conversation/navigation/ConversationNavRouteState.class +**/ui/conversation/navigation/ConversationPendingLaunchPayload.class +**/ui/conversation/screen/ConversationSimSheetState.class +**/ui/conversation/screen/ConversationSimSheetState$*.class diff --git a/app/jacoco-rules/instrumented/included-class-patterns.txt b/app/jacoco-rules/instrumented/included-class-patterns.txt new file mode 100644 index 000000000..b61015a9f --- /dev/null +++ b/app/jacoco-rules/instrumented/included-class-patterns.txt @@ -0,0 +1,5 @@ +# Instrumented-test class-directory include patterns. +com/android/messaging/ui/appsettings/** +com/android/messaging/ui/contact/** +com/android/messaging/ui/conversation/** +com/android/messaging/ui/core/** diff --git a/app/jacoco-rules/instrumented/measured-composable-sources.txt b/app/jacoco-rules/instrumented/measured-composable-sources.txt new file mode 100644 index 000000000..ac02943aa --- /dev/null +++ b/app/jacoco-rules/instrumented/measured-composable-sources.txt @@ -0,0 +1,2 @@ +# Composable source files intentionally measured by the instrumented coverage track. +# Currently empty; composables measured by the unit track are excluded from this track. diff --git a/app/jacoco-rules/instrumented/measured-non-composable-sources.txt b/app/jacoco-rules/instrumented/measured-non-composable-sources.txt new file mode 100644 index 000000000..fd4434ceb --- /dev/null +++ b/app/jacoco-rules/instrumented/measured-non-composable-sources.txt @@ -0,0 +1,2 @@ +# Non-composable UI source files intentionally measured by the instrumented coverage track. +conversation/ConversationActivity.kt diff --git a/app/jacoco-rules/unit/excluded-class-patterns.txt b/app/jacoco-rules/unit/excluded-class-patterns.txt new file mode 100644 index 000000000..59e6bf819 --- /dev/null +++ b/app/jacoco-rules/unit/excluded-class-patterns.txt @@ -0,0 +1,51 @@ +# Unit-test-only class-directory exclude patterns. + +# Android framework entry points belong in the instrumented coverage track. +**/*Activity.class +**/*Activity$*.class +**/*Fragment.class +**/*Fragment$*.class +**/*Receiver.class +**/*Receiver$*.class +**/*Service.class +**/*Service$*.class + +# Non-logic or non-unit-testable classes. +**/*Exception.class +**/*Exception$*.class +**/ExceptionsKt.class +**/domain/subscriptionsettings/usecase/SetSubscriptionPhoneNumberImpl.class +**/ui/conversation/ConversationTestTagsKt.class +**/ui/conversation/composer/ui/AudioRecordingBarVisualState.class +**/ui/conversation/composer/ui/ConversationAudioRecordingGestureController.class +**/ui/conversation/composer/ui/ConversationAudioRecordingLockAffordanceVisualState.class +**/ui/conversation/composer/ui/ConversationComposeBarPresentation.class +**/ui/conversation/composer/ui/ConversationComposeInputState.class +**/ui/conversation/composer/ui/ConversationSendActionButtonVisualState.class +**/ui/conversation/mediapicker/ConversationMediaPickerOverlayMode.class +**/ui/conversation/mediapicker/camera/ConversationCameraControllerImpl*.class +**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureRecordingStopVisualState.class +**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterPhase.class +**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterSurfaceVisualState.class +**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterVisualState.class +**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureVideoCenterDotVisualState.class +**/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundSelection.class +**/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundState.class +**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCardContentState.class +**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCardState.class +**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerLayout.class +**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.class +**/ui/conversation/messages/ui/ConversationMessagesItemContentType.class +**/ui/conversation/messages/ui/ConversationMessagesItemPresentation.class +**/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentColors.class +**/ui/conversation/messages/ui/message/ConversationMessageAvatarColors.class +**/ui/conversation/messages/ui/message/ConversationMessageBubbleLayoutMode.class +**/ui/conversation/messages/ui/message/ConversationMessageLayout.class +**/ui/conversation/metadata/ui/ConversationTopAppBarOverflowVisibility.class +**/ui/conversation/metadata/ui/ConversationTopAppBarPresentation.class +**/ui/conversation/navigation/ConversationNavRouteState.class +**/ui/conversation/navigation/ConversationPendingLaunchPayload.class +**/ui/conversation/recipientpicker/component/RecipientSelectionHiddenBackspaceTargetInputTransformation.class +**/ui/conversation/recipientpicker/component/RecipientSelectionHiddenBackspaceTargetOutputTransformation.class +**/ui/conversation/screen/AudioRecordingStartMode.class +**/ui/conversation/screen/ConversationLatestScrollSnapshot.class diff --git a/app/jacoco-rules/unit/included-class-patterns.txt b/app/jacoco-rules/unit/included-class-patterns.txt new file mode 100644 index 000000000..f24917dfd --- /dev/null +++ b/app/jacoco-rules/unit/included-class-patterns.txt @@ -0,0 +1,10 @@ +# Unit-test class-directory include patterns. +com/android/messaging/data/** +com/android/messaging/domain/** +com/android/messaging/sms/** +com/android/messaging/ui/appsettings/** +com/android/messaging/ui/contact/** +com/android/messaging/ui/conversation/** +com/android/messaging/ui/core/** +com/android/messaging/util/core/** +com/android/messaging/util/db/** diff --git a/app/jacoco-rules/unit/measured-composable-sources.txt b/app/jacoco-rules/unit/measured-composable-sources.txt new file mode 100644 index 000000000..2470b7278 --- /dev/null +++ b/app/jacoco-rules/unit/measured-composable-sources.txt @@ -0,0 +1,68 @@ +# Composable source files intentionally measured by the unit coverage track. +conversation/ConversationSubscriptionLabelResolver.kt +conversation/addparticipants/AddParticipantsScreen.kt +conversation/attachment/ui/ConversationMediaThumbnail.kt +conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt +conversation/composer/ui/ConversationAttachmentPreview.kt +conversation/composer/ui/ConversationAudioRecordingBar.kt +conversation/composer/ui/ConversationComposeBar.kt +conversation/composer/ui/ConversationComposeMessageField.kt +conversation/composer/ui/ConversationComposerSection.kt +conversation/composer/ui/ConversationSendActionButton.kt +conversation/composer/ui/ConversationSendActionButtonGesture.kt +conversation/composer/ui/ConversationSimAvatar.kt +conversation/composer/ui/ConversationSimSelectorSheet.kt +conversation/composer/ui/ConversationSubjectChip.kt +conversation/entry/NewChatScreen.kt +conversation/entry/NewGroupButton.kt +conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt +conversation/mediapicker/ConversationMediaPickerCaptureScene.kt +conversation/mediapicker/ConversationMediaPickerScaffold.kt +conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt +conversation/mediapicker/ConversationMediaPickerState.kt +conversation/mediapicker/camera/ConversationCameraEffects.kt +conversation/mediapicker/component/ConversationMediaPickerShared.kt +conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt +conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt +conversation/mediapicker/component/review/ConversationMediaPickerReview.kt +conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt +conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt +conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt +conversation/messages/ui/ConversationMessageSeparator.kt +conversation/messages/ui/ConversationMessages.kt +conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt +conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt +conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt +conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt +conversation/messages/ui/attachment/ConversationMessageAttachments.kt +conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt +conversation/messages/ui/attachment/ConversationVisualAttachments.kt +conversation/messages/ui/message/ConversationMessage.kt +conversation/messages/ui/message/ConversationMessageAvatar.kt +conversation/messages/ui/message/ConversationMessageBubble.kt +conversation/messages/ui/message/ConversationMessageMetadata.kt +conversation/messages/ui/message/ConversationMessageRows.kt +conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt +conversation/messages/ui/message/ConversationMmsDownloadBody.kt +conversation/messages/ui/text/ConversationMessageText.kt +conversation/metadata/ui/ConversationTopAppBar.kt +conversation/recipientpicker/RecipientPickerScreen.kt +conversation/recipientpicker/component/MultiDestinationContactRow.kt +conversation/recipientpicker/component/RecipientSelectionArmedRecipient.kt +conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt +conversation/recipientpicker/component/RecipientSelectionContactRow.kt +conversation/recipientpicker/component/RecipientSelectionContactsContent.kt +conversation/recipientpicker/component/RecipientSelectionContent.kt +conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt +conversation/recipientpicker/component/RecipientSelectionQueryCard.kt +conversation/recipientpicker/component/RecipientSelectionQueryField.kt +conversation/recipientpicker/component/RecipientSelectionSelectedRecipientChips.kt +conversation/recipientpicker/component/simselector/NewChatSimSelector.kt +conversation/screen/ConversationScreen.kt +conversation/screen/ConversationScreenContent.kt +conversation/screen/ConversationScreenDialogs.kt +conversation/screen/ConversationScreenEffects.kt +conversation/screen/ConversationScreenRoute.kt +conversation/screen/ConversationSelectionTopAppBar.kt +conversation/screen/ConversationSimSheetState.kt From 41629eeec003c85d48ec12e466c85702a96b08f2 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 01:19:33 +0300 Subject: [PATCH 25/38] Clean up the JaCoCo Gradle script --- app/jacoco.gradle.kts | 616 ++++++++++++++++++++++++++++++------------ 1 file changed, 440 insertions(+), 176 deletions(-) diff --git a/app/jacoco.gradle.kts b/app/jacoco.gradle.kts index 6f7ebd31b..5e1971cd7 100644 --- a/app/jacoco.gradle.kts +++ b/app/jacoco.gradle.kts @@ -1,167 +1,442 @@ +import java.io.File +import java.math.BigDecimal +import org.gradle.api.file.FileCollection import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.testing.jacoco.tasks.JacocoReportBase -val unitTestIncludedPackages: List = listOf( - "com/android/messaging/data/**", - "com/android/messaging/domain/**", - "com/android/messaging/sms/**", - "com/android/messaging/ui/appsettings/**", - "com/android/messaging/ui/contact/**", - "com/android/messaging/ui/conversation/**", - "com/android/messaging/ui/core/**", - "com/android/messaging/util/core/**", - "com/android/messaging/util/db/**", +private val jacocoRulesDirPath = "jacoco-rules" +private val sourceDirPath = "../src" +private val uiSourceDirPath = "../src/com/android/messaging/ui" +private val buildDirPath = "build" + +private val unitClassRoot = + "build/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes" +private val instrumentedClassRoot = + "build/intermediates/classes/debug/transformDebugClassesWithAsm/dirs" +private val debugJavaClassRoot = + "build/intermediates/javac/debug/compileDebugJavaWithJavac/classes" + +private val unitExecutionDataPattern = + "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec" +private val instrumentedExecutionDataPattern = + "outputs/code_coverage/debugAndroidTest/connected/**/*.ec" + +private val unitMinCoverageProperty = "unitTestMinCoverage" +private val instrumentedMinCoverageProperty = "androidTestMinCoverage" + +private val reportingGroup = "Reporting" +private val verificationGroup = "Verification" +private val kotlinExtension = "kt" +private val composableAnnotation = "@Composable" +private val ruleCommentPrefix = "#" + +private enum class CoverageTrack { + UNIT, + INSTRUMENTED, +} + +private data class JacocoRuleLine( + val rule: String, + val location: String, ) -val androidTestIncludedPackages: List = listOf( - "com/android/messaging/data/appsettings/**", - "com/android/messaging/domain/subscriptionsettings/**", - "com/android/messaging/ui/appsettings/**", - "com/android/messaging/ui/contact/**", - "com/android/messaging/ui/conversation/**", - "com/android/messaging/ui/core/**", +private data class TrackRules( + val includedClassPatterns: List, + val excludedClassPatterns: List, + val measuredComposableSourcePaths: Set, + val measuredNonComposableSourcePaths: Set = emptySet(), ) -val coverageExcludedClasses: List = listOf( - "**/_Factory*", - "**/Hilt_*", - "**/_HiltModules*", - "**/_GeneratedInjector*", - "**/_MembersInjector*", - "**/Dagger*", - "**/*ComposableSingletons*", - $$"**/*$serializer", - "**/model/**", - $$$"**/*$$inlined$*", - $$"**/*$invokeSuspend$*", - $$"**/*$DefaultImpls*", -) + (0..9).flatMap { i -> - listOf( - $$"**/*$$${i}.class", - $$"**/*$?$${i}.class", - $$"**/*$??$${i}.class", - ) +private data class UiKotlinSourceFile( + val sourceFile: File, + val relativePath: String, + val packagePath: String, + val content: String, +) { + val hasComposableAnnotation: Boolean + get() { + return content.contains(composableAnnotation) + } } -val unitTestExcludedClasses: List = listOf( - "**/*Exception.class", - "**/*Exception$*.class", - "**/*Activity.class", - "**/*Activity$*.class", - "**/ExceptionsKt.class", - "**/*Fragment.class", - "**/*Fragment$*.class", - "**/*Receiver.class", - "**/*Receiver$*.class", - "**/*Service.class", - "**/*Service$*.class", - "**/data/appsettings/repository/AppSettingsRepositoryImpl.class", - "**/domain/subscriptionsettings/usecase/SetSubscriptionPhoneNumberImpl.class", - "**/ui/appsettings/general/mapper/AppSettingsUiStateMapperImpl.class", - "**/ui/appsettings/subscription/mapper/SubscriptionSettingsUiStateMapperImpl.class", - "**/ui/conversation/ConversationTestTagsKt.class", - "**/ui/conversation/composer/ui/*.class", - "**/ui/conversation/mediapicker/camera/ConversationCameraControllerImpl*.class", - "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureRecordingStopVisualState.class", - "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterPhase.class", - "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterSurfaceVisualState.class", - "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterVisualState.class", - "**/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureVideoCenterDotVisualState.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundSelection.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackgroundState.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCardContentState.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCardState.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerCoordinator.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerLayout.class", - "**/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.class", - "**/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentColors.class", - "**/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.class", - "**/ui/conversation/metadata/ui/ConversationTopAppBarOverflowVisibility.class", - "**/ui/conversation/metadata/ui/ConversationTopAppBarPresentation.class", +private val jacocoRulesDir: File = file(jacocoRulesDirPath) +private val uiSourceDir: File = file(uiSourceDirPath) +private val kotlinTypeNameRegex = Regex( + """\b(?:(?:annotation|data|enum|fun|sealed|value)\s+)?(?:class|interface|object)\s+([A-Za-z_][A-Za-z0-9_]*)""", ) -fun getComposableUnitExcludes(): List { - val excludes = mutableListOf() - val uiSrcDir = file("../src/com/android/messaging/ui") - if (uiSrcDir.exists()) { - uiSrcDir.walkTopDown().forEach { file -> - if (file.isFile && file.extension == "kt") { - val content = file.readText() - if (content.contains("@Composable")) { - val baseName = file.nameWithoutExtension - excludes.add("**/ui/**/${baseName}Kt*.class") - } +// Rule Loading + +private fun readJacocoRuleLines(relativePath: String): List { + val rulesFile = jacocoRulesDir.resolve(relativePath) + check(rulesFile.isFile) { + "Missing Jacoco rule file: ${rulesFile.path}" + } + + val ruleLines = rulesFile.readLines() + .mapIndexedNotNull { lineIndex, rawLine -> + val rule = rawLine.trim() + when { + rule.isEmpty() || rule.startsWith(ruleCommentPrefix) -> null + else -> JacocoRuleLine( + rule = rule, + location = "${rulesFile.path}:${lineIndex + 1}", + ) } } + + validateUniqueRuleLines(ruleLines = ruleLines) + return ruleLines.map { ruleLine -> ruleLine.rule } +} + +private fun validateUniqueRuleLines(ruleLines: List) { + val seenRules = mutableSetOf() + ruleLines.forEach { ruleLine -> + check(!ruleLine.rule.contains('\\')) { + "Jacoco rule must use '/' separators at ${ruleLine.location}: ${ruleLine.rule}" + } + check(seenRules.add(ruleLine.rule)) { + "Duplicate Jacoco rule at ${ruleLine.location}: ${ruleLine.rule}" + } + } +} + +private fun readJacocoClassPathPatterns(relativePath: String): List { + val patterns = readJacocoRuleLines(relativePath = relativePath) + patterns.forEach { pattern -> + validateClassPathPattern( + pattern = pattern, + relativePath = relativePath, + ) + } + return patterns +} + +private fun validateClassPathPattern(pattern: String, relativePath: String) { + check(!pattern.startsWith("/")) { + "Jacoco class pattern must be relative in $relativePath: $pattern" + } + check(!pattern.endsWith(".kt")) { + "Jacoco class pattern must not point to source in $relativePath: $pattern" + } +} + +private fun readJacocoUiSourcePaths(relativePath: String): Set { + val sourcePaths = readJacocoRuleLines(relativePath = relativePath) + sourcePaths.forEach { sourcePath -> + validateUiSourcePath( + sourcePath = sourcePath, + relativePath = relativePath, + ) + } + return LinkedHashSet(sourcePaths) +} + +private fun validateUiSourcePath(sourcePath: String, relativePath: String) { + check(!sourcePath.startsWith("/")) { + "Jacoco UI source path must be relative in $relativePath: $sourcePath" + } + check(!sourcePath.startsWith("ui/")) { + "Jacoco UI source path is relative to src/com/android/messaging/ui: $sourcePath" + } + check(sourcePath.endsWith(".kt")) { + "Jacoco UI source path must point to a Kotlin file in $relativePath: $sourcePath" + } +} + +private fun getGeneratedSyntheticClassPatterns(): List { + return (0..9).flatMap { syntheticSuffixIndex -> + listOf( + "**/*\$$syntheticSuffixIndex.class", + "**/*\$?\$$syntheticSuffixIndex.class", + "**/*\$??\$$syntheticSuffixIndex.class", + ) + } +} + +private val commonExcludedClassPatterns: List = readJacocoClassPathPatterns( + relativePath = "common/excluded-class-patterns.txt", +) + getGeneratedSyntheticClassPatterns() + +private val unitRules = TrackRules( + includedClassPatterns = readJacocoClassPathPatterns( + relativePath = "unit/included-class-patterns.txt", + ), + excludedClassPatterns = readJacocoClassPathPatterns( + relativePath = "unit/excluded-class-patterns.txt", + ), + measuredComposableSourcePaths = readJacocoUiSourcePaths( + relativePath = "unit/measured-composable-sources.txt", + ), +) + +private val instrumentedRules = TrackRules( + includedClassPatterns = readJacocoClassPathPatterns( + relativePath = "instrumented/included-class-patterns.txt", + ), + excludedClassPatterns = readJacocoClassPathPatterns( + relativePath = "instrumented/excluded-class-patterns.txt", + ), + measuredComposableSourcePaths = readJacocoUiSourcePaths( + relativePath = "instrumented/measured-composable-sources.txt", + ), + measuredNonComposableSourcePaths = readJacocoUiSourcePaths( + relativePath = "instrumented/measured-non-composable-sources.txt", + ), +) + +// Track Configuration + +private fun getTrackRules(track: CoverageTrack): TrackRules { + return when (track) { + CoverageTrack.UNIT -> unitRules + CoverageTrack.INSTRUMENTED -> instrumentedRules + } +} + +private fun getClassRoot(track: CoverageTrack): String { + return when (track) { + CoverageTrack.UNIT -> unitClassRoot + CoverageTrack.INSTRUMENTED -> instrumentedClassRoot + } +} + +private fun getExecutionDataPattern(track: CoverageTrack): String { + return when (track) { + CoverageTrack.UNIT -> unitExecutionDataPattern + CoverageTrack.INSTRUMENTED -> instrumentedExecutionDataPattern + } +} + +private fun getMinimumCoveragePropertyName(track: CoverageTrack): String { + return when (track) { + CoverageTrack.UNIT -> unitMinCoverageProperty + CoverageTrack.INSTRUMENTED -> instrumentedMinCoverageProperty + } +} + +// Kotlin Source Analysis + +private fun getUiKotlinSourceFiles(): List { + if (!uiSourceDir.exists()) { + return emptyList() + } + + return uiSourceDir + .walkTopDown() + .filter { sourceFile -> sourceFile.isFile && sourceFile.extension == kotlinExtension } + .map { sourceFile -> createUiKotlinSourceFile(sourceFile = sourceFile) } + .toList() +} + +private fun createUiKotlinSourceFile(sourceFile: File): UiKotlinSourceFile { + return UiKotlinSourceFile( + sourceFile = sourceFile, + relativePath = getUiSourceRelativePath(sourceFile = sourceFile), + packagePath = getUiPackagePath(sourceFile = sourceFile), + content = sourceFile.readText(), + ) +} + +private fun getUiSourceRelativePath(sourceFile: File): String { + return sourceFile + .relativeTo(base = uiSourceDir) + .path + .replace(oldChar = File.separatorChar, newChar = '/') +} + +private fun getUiPackagePath(sourceFile: File): String { + val relativeParentPath = sourceFile.parentFile + .relativeTo(base = uiSourceDir) + .path + .replace(oldChar = File.separatorChar, newChar = '/') + + return when { + relativeParentPath.isEmpty() -> "ui" + else -> "ui/$relativeParentPath" } - return excludes } -fun getJavaExclusions(): List { - val javaExcludes = mutableListOf() - val javacDir = file("build/intermediates/javac/debug/compileDebugJavaWithJavac/classes") - if (javacDir.exists()) { - javacDir.walkTopDown().forEach { file -> - if (file.isFile && file.extension == "class") { - val relativePath = file.relativeTo(javacDir).path - javaExcludes.add(relativePath) +private fun getKotlinTypeNames(content: String): List { + return kotlinTypeNameRegex.findAll(input = content) + .map { matchResult -> matchResult.groupValues[1] } + .toList() +} + +private fun UiKotlinSourceFile.fileFacadeClassPattern(): String { + val baseName = sourceFile.nameWithoutExtension + return "**/$packagePath/${baseName}Kt*.class" +} + +private fun UiKotlinSourceFile.classPatterns(): List { + val baseName = sourceFile.nameWithoutExtension + val classNames = (listOf("${baseName}Kt") + getKotlinTypeNames(content = content)) + .distinct() + + return classNames.map { className -> + "**/$packagePath/$className*.class" + } +} + +private fun getMeasuredUiSourceClassPatterns( + sourcePaths: Set, + expectedComposable: Boolean, + description: String, +): List { + if (!uiSourceDir.exists()) { + return emptyList() + } + + return sourcePaths + .flatMap { sourcePath -> + val sourceFile = uiSourceDir.resolve(sourcePath) + check(sourceFile.isFile) { + "Missing $description: ${sourceFile.path}" + } + + val source = createUiKotlinSourceFile(sourceFile = sourceFile) + check(source.hasComposableAnnotation == expectedComposable) { + "Unexpected composable state for $description: ${sourceFile.path}" } + + source.classPatterns() } + .distinct() +} + +// Class Pattern Assembly + +private fun getExplicitIncludeClassPatterns(track: CoverageTrack): List { + return when (track) { + CoverageTrack.UNIT -> getUnitMeasuredComposableClassPatterns() + CoverageTrack.INSTRUMENTED -> getInstrumentedMeasuredSourceClassPatterns() } - return javaExcludes -} - -fun getKotlinClassTree(isUnitTestTrack: Boolean): FileTree { - val includedPackages = - if (isUnitTestTrack) unitTestIncludedPackages else androidTestIncludedPackages - val baseDir = if (isUnitTestTrack) { - "build/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes" - } else { - "build/intermediates/classes/debug/transformDebugClassesWithAsm/dirs" - } - return fileTree(baseDir) { - include(includedPackages) - exclude(coverageExcludedClasses) - if (isUnitTestTrack) { - exclude(getComposableUnitExcludes()) - exclude(unitTestExcludedClasses) - } else { - exclude(getJavaExclusions()) +} + +private fun getUnitMeasuredComposableClassPatterns(): List { + return getMeasuredUiSourceClassPatterns( + sourcePaths = unitRules.measuredComposableSourcePaths, + expectedComposable = true, + description = "unit-measured composable source file", + ) +} + +private fun getInstrumentedMeasuredSourceClassPatterns(): List { + val composableClassPatterns = getMeasuredUiSourceClassPatterns( + sourcePaths = instrumentedRules.measuredComposableSourcePaths, + expectedComposable = true, + description = "instrumented-test-measured composable source file", + ) + val nonComposableClassPatterns = getMeasuredUiSourceClassPatterns( + sourcePaths = instrumentedRules.measuredNonComposableSourcePaths, + expectedComposable = false, + description = "instrumented-test-measured non-composable source file", + ) + + return composableClassPatterns + nonComposableClassPatterns +} + +private fun getTrackExcludedClassPatterns(track: CoverageTrack): List { + return when (track) { + CoverageTrack.UNIT -> getUnitTrackExcludedClassPatterns() + CoverageTrack.INSTRUMENTED -> getInstrumentedTrackExcludedClassPatterns() + } +} + +private fun getUnitTrackExcludedClassPatterns(): List { + return getUnmeasuredComposableFileFacadePatterns() + unitRules.excludedClassPatterns +} + +private fun getUnmeasuredComposableFileFacadePatterns(): List { + return getUiKotlinSourceFiles() + .filter { source -> source.hasComposableAnnotation } + .filter { source -> source.relativePath !in unitRules.measuredComposableSourcePaths } + .map { source -> source.fileFacadeClassPattern() } +} + +private fun getInstrumentedTrackExcludedClassPatterns(): List { + return getInstrumentedLogicClassPatterns() + + getUnitMeasuredComposableClassPatterns() + + instrumentedRules.excludedClassPatterns +} + +private fun getInstrumentedLogicClassPatterns(): List { + return getUiKotlinSourceFiles() + .filter { source -> !source.hasComposableAnnotation } + .filter { source -> + source.relativePath !in instrumentedRules.measuredNonComposableSourcePaths } + .flatMap { source -> source.classPatterns() } + .distinct() +} + +private fun getNonOverridableExcludedClassPatterns(track: CoverageTrack): List { + val generatedOrStructuralExcludes = commonExcludedClassPatterns + val compiledJavaExcludes = when (track) { + CoverageTrack.UNIT -> emptyList() + CoverageTrack.INSTRUMENTED -> getDebugJavaClassFilePaths() + } + + return generatedOrStructuralExcludes + compiledJavaExcludes +} + +private fun getDebugJavaClassFilePaths(): List { + val javaClassRoot = file(debugJavaClassRoot) + if (!javaClassRoot.exists()) { + return emptyList() } + + return javaClassRoot + .walkTopDown() + .filter { classFile -> classFile.isFile && classFile.extension == "class" } + .map { classFile -> classFile.relativeTo(base = javaClassRoot).path } + .toList() } -tasks.register("jacocoUnitTestReport") { - dependsOn("testDebugUnitTest") - group = "Reporting" - description = "Generate Jacoco coverage report for the unit tests." +private fun getKotlinClassTree(track: CoverageTrack): FileCollection { + val rules = getTrackRules(track = track) + val classRoot = getClassRoot(track = track) + val explicitIncludes = getExplicitIncludeClassPatterns(track = track) + val nonOverridableExcludes = getNonOverridableExcludedClassPatterns(track = track) - reports { - xml.required.set(true) - html.required.set(true) + // Gradle excludes win inside one PatternSet, so explicit includes live in a second tree. + val filteredClassTree = fileTree(classRoot) { + include(rules.includedClassPatterns) + exclude(nonOverridableExcludes) + exclude(explicitIncludes) + exclude(getTrackExcludedClassPatterns(track = track)) + } + val explicitlyIncludedClassTree = fileTree(classRoot) { + include(explicitIncludes) + exclude(nonOverridableExcludes) } - classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = true)) - sourceDirectories.setFrom(files("../src")) - executionData.setFrom( - fileTree("build") { - include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") - }, - ) + return files(filteredClassTree, explicitlyIncludedClassTree) } -tasks.register("jacocoUnitTestVerification") { - dependsOn("jacocoUnitTestReport") - group = "Verification" - description = "Verify Jacoco coverage for unit tests." +// Task Configuration - classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = true)) - sourceDirectories.setFrom(files("../src")) +private fun JacocoReportBase.configureCoverageInputs(track: CoverageTrack) { + classDirectories.setFrom(getKotlinClassTree(track = track)) + sourceDirectories.setFrom(files(sourceDirPath)) executionData.setFrom( - fileTree("build") { - include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") + fileTree(buildDirPath) { + include(getExecutionDataPattern(track = track)) }, ) +} + +private fun JacocoReport.configureCoverageReport(track: CoverageTrack) { + reports { + xml.required.set(true) + html.required.set(true) + } + configureCoverageInputs(track = track) +} + +private fun JacocoCoverageVerification.configureCoverageVerification(track: CoverageTrack) { + configureCoverageInputs(track = track) violationRules { rule { @@ -169,62 +444,51 @@ tasks.register("jacocoUnitTestVerification") { limit { counter = "INSTRUCTION" value = "COVEREDRATIO" - val minCoverage = project - .findProperty("unitTestMinCoverage") - ?.toString() - ?.toDoubleOrNull() - ?: 0.0 - minimum = (minCoverage / 100.0).toBigDecimal() + minimum = getMinimumCoverage( + propertyName = getMinimumCoveragePropertyName(track = track), + ) } } } } +private fun getMinimumCoverage(propertyName: String): BigDecimal { + val minimumPercent = project + .findProperty(propertyName) + ?.toString() + ?.toDoubleOrNull() + ?: 0.0 + + return (minimumPercent / 100.0).toBigDecimal() +} + +tasks.register("jacocoUnitTestReport") { + dependsOn("testDebugUnitTest") + group = reportingGroup + description = "Generate Jacoco coverage report for the unit tests." + + configureCoverageReport(track = CoverageTrack.UNIT) +} + +tasks.register("jacocoUnitTestVerification") { + dependsOn("jacocoUnitTestReport") + group = verificationGroup + description = "Verify Jacoco coverage for unit tests." + + configureCoverageVerification(track = CoverageTrack.UNIT) +} + tasks.register("jacocoAndroidTestReport") { - group = "Reporting" + group = reportingGroup description = "Generate Jacoco coverage report for instrumented (Android) tests." - reports { - xml.required.set(true) - html.required.set(true) - } - - classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = false)) - sourceDirectories.setFrom(files("../src")) - executionData.setFrom( - fileTree("build") { - include("outputs/code_coverage/debugAndroidTest/connected/**/*.ec") - }, - ) + configureCoverageReport(track = CoverageTrack.INSTRUMENTED) } tasks.register("jacocoAndroidTestVerification") { dependsOn("jacocoAndroidTestReport") - group = "Verification" + group = verificationGroup description = "Verify Jacoco coverage for instrumented (Android) tests." - classDirectories.setFrom(getKotlinClassTree(isUnitTestTrack = false)) - sourceDirectories.setFrom(files("../src")) - executionData.setFrom( - fileTree("build") { - include("outputs/code_coverage/debugAndroidTest/connected/**/*.ec") - }, - ) - - violationRules { - rule { - element = "BUNDLE" - limit { - counter = "INSTRUCTION" - value = "COVEREDRATIO" - val minCoverage = project - .findProperty("androidTestMinCoverage") - ?.toString() - ?.toDoubleOrNull() - ?: 0.0 - - minimum = (minCoverage / 100.0).toBigDecimal() - } - } - } + configureCoverageVerification(track = CoverageTrack.INSTRUMENTED) } From 9ffe8029808d718a61ed6a786f514854e6d60c36 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 18:03:19 +0300 Subject: [PATCH 26/38] Add pull request CI checks --- .github/workflows/build.yml | 128 ++++++++++++++++-- .github/workflows/validate-gradle-wrapper.yml | 23 +++- 2 files changed, 139 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 50e01a8ff..d74bb1598 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,20 +1,130 @@ -name: Build application +name: Pull request checks -on: [pull_request, push] +on: + pull_request: + types: + - opened + - reopened + - synchronize + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: - build: + build-debug-apk: + name: Build debug APK + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out sources + uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Assemble debug APK + run: ./gradlew :app:assembleDebug --no-daemon --stacktrace --console=plain + + ktlint: + name: ktlintCheck runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v5 + - name: Check out sources + uses: actions/checkout@v6 with: submodules: true - - name: Set up JDK 21 + + - name: Set up JDK 17 uses: actions/setup-java@v5 with: - distribution: 'temurin' - java-version: 21 + distribution: temurin + java-version: 17 cache: gradle - - name: Build with Gradle - run: ./gradlew build --no-daemon + + - name: Run ktlintCheck + run: ./gradlew ktlintCheck --no-daemon --stacktrace --console=plain + + - name: Upload ktlint reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ktlint-reports + path: | + build/reports/ktlint/ + app/build/reports/ktlint/ + if-no-files-found: ignore + + detekt: + name: Detekt + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out sources + uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Run Detekt + run: ./gradlew :app:detekt --no-daemon --stacktrace --console=plain + + - name: Upload Detekt reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: detekt-reports + path: app/build/reports/detekt/ + if-no-files-found: ignore + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out sources + uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up JDKs + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: | + 17 + 21 + cache: gradle + + - name: Run unit tests + run: ./gradlew :app:testDebugUnitTest --no-daemon --stacktrace --console=plain + + - name: Upload unit test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports + path: | + app/build/reports/tests/testDebugUnitTest/ + app/build/test-results/testDebugUnitTest/ + if-no-files-found: ignore diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml index 1f95d5adc..509db1aea 100644 --- a/.github/workflows/validate-gradle-wrapper.yml +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -1,11 +1,28 @@ name: Validate Gradle Wrapper -on: [pull_request, push] +on: + pull_request: + types: + - opened + - reopened + - synchronize + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: validation: name: Validation runs-on: ubuntu-latest + timeout-minutes: 10 + steps: - - uses: actions/checkout@v5 - - uses: gradle/actions/wrapper-validation@v6 + - name: Check out sources + uses: actions/checkout@v6 + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v6 From 715bccd638fce15cd3ea5fe4424b6223f7077331 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 19:11:00 +0300 Subject: [PATCH 27/38] Add unit coverage gate --- .github/workflows/build.yml | 9 +++-- app/jacoco.gradle.kts | 66 +++++++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d74bb1598..bdcc74206 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,8 +116,12 @@ jobs: 21 cache: gradle - - name: Run unit tests - run: ./gradlew :app:testDebugUnitTest --no-daemon --stacktrace --console=plain + - name: Run unit tests and coverage gate + run: > + ./gradlew :app:jacocoUnitTestVerification + -PunitTestMinCoverage=80 + -PunitTestMinBranchCoverage=60 + --no-daemon --stacktrace --console=plain - name: Upload unit test reports if: failure() @@ -125,6 +129,7 @@ jobs: with: name: unit-test-reports path: | + app/build/reports/jacoco/jacocoUnitTestReport/ app/build/reports/tests/testDebugUnitTest/ app/build/test-results/testDebugUnitTest/ if-no-files-found: ignore diff --git a/app/jacoco.gradle.kts b/app/jacoco.gradle.kts index 5e1971cd7..b48aec2e3 100644 --- a/app/jacoco.gradle.kts +++ b/app/jacoco.gradle.kts @@ -22,14 +22,17 @@ private val unitExecutionDataPattern = private val instrumentedExecutionDataPattern = "outputs/code_coverage/debugAndroidTest/connected/**/*.ec" -private val unitMinCoverageProperty = "unitTestMinCoverage" -private val instrumentedMinCoverageProperty = "androidTestMinCoverage" +private val unitMinInstructionCoverageProperty = "unitTestMinCoverage" +private val unitMinBranchCoverageProperty = "unitTestMinBranchCoverage" +private val instrumentedMinInstructionCoverageProperty = "androidTestMinCoverage" +private val instrumentedMinBranchCoverageProperty = "androidTestMinBranchCoverage" private val reportingGroup = "Reporting" private val verificationGroup = "Verification" private val kotlinExtension = "kt" private val composableAnnotation = "@Composable" private val ruleCommentPrefix = "#" +private val coveragePercentDivisor = BigDecimal("100") private enum class CoverageTrack { UNIT, @@ -48,6 +51,11 @@ private data class TrackRules( val measuredNonComposableSourcePaths: Set = emptySet(), ) +private data class CoverageMinimum( + val counter: String, + val propertyName: String, +) + private data class UiKotlinSourceFile( val sourceFile: File, val relativePath: String, @@ -209,10 +217,28 @@ private fun getExecutionDataPattern(track: CoverageTrack): String { } } -private fun getMinimumCoveragePropertyName(track: CoverageTrack): String { +private fun getCoverageMinimums(track: CoverageTrack): List { return when (track) { - CoverageTrack.UNIT -> unitMinCoverageProperty - CoverageTrack.INSTRUMENTED -> instrumentedMinCoverageProperty + CoverageTrack.UNIT -> listOf( + CoverageMinimum( + counter = "INSTRUCTION", + propertyName = unitMinInstructionCoverageProperty, + ), + CoverageMinimum( + counter = "BRANCH", + propertyName = unitMinBranchCoverageProperty, + ), + ) + CoverageTrack.INSTRUMENTED -> listOf( + CoverageMinimum( + counter = "INSTRUCTION", + propertyName = instrumentedMinInstructionCoverageProperty, + ), + CoverageMinimum( + counter = "BRANCH", + propertyName = instrumentedMinBranchCoverageProperty, + ), + ) } } @@ -441,25 +467,31 @@ private fun JacocoCoverageVerification.configureCoverageVerification(track: Cove violationRules { rule { element = "BUNDLE" - limit { - counter = "INSTRUCTION" - value = "COVEREDRATIO" - minimum = getMinimumCoverage( - propertyName = getMinimumCoveragePropertyName(track = track), - ) + getCoverageMinimums(track = track).forEach { coverageMinimum -> + limit { + counter = coverageMinimum.counter + value = "COVEREDRATIO" + minimum = getMinimumCoverage(propertyName = coverageMinimum.propertyName) + } } } } } private fun getMinimumCoverage(propertyName: String): BigDecimal { - val minimumPercent = project - .findProperty(propertyName) - ?.toString() - ?.toDoubleOrNull() - ?: 0.0 + val rawMinimumPercent = project.findProperty(propertyName)?.toString() + ?: return BigDecimal.ZERO + + val minimumPercent = rawMinimumPercent.toBigDecimalOrNull() + check(minimumPercent != null) { + "Jacoco minimum coverage property must be numeric: -P$propertyName=$rawMinimumPercent" + } + check(minimumPercent in BigDecimal.ZERO..coveragePercentDivisor) { + "Jacoco minimum coverage property must be between 0 and 100: " + + "-P$propertyName=$rawMinimumPercent" + } - return (minimumPercent / 100.0).toBigDecimal() + return minimumPercent.divide(coveragePercentDivisor) } tasks.register("jacocoUnitTestReport") { From fe12f566e8841d720310d27b8cca44ea67ff23fe Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 19:55:06 +0300 Subject: [PATCH 28/38] Add Android 16 instrumented PR tests --- .github/workflows/build.yml | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bdcc74206..eb7419de6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,3 +133,46 @@ jobs: app/build/reports/tests/testDebugUnitTest/ app/build/test-results/testDebugUnitTest/ if-no-files-found: ignore + + instrumented-tests: + name: Instrumented tests (Android 16 x86_64) + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Check out sources + uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a + with: + api-level: 36 + arch: x86_64 + target: default + disable-animations: true + script: ./gradlew :app:connectedDebugAndroidTest --no-daemon --stacktrace --console=plain + + - name: Upload instrumented test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: instrumented-test-reports + path: | + app/build/outputs/androidTest-results/connected/ + app/build/reports/androidTests/connected/ + if-no-files-found: ignore From 564bf5e5a37e8b9f983572b05396aeccb311a4f5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 2 Jun 2026 23:33:49 +0300 Subject: [PATCH 29/38] Add instrumented coverage gate --- .github/workflows/build.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb7419de6..c443c7394 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -158,14 +158,19 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Run instrumented tests + - name: Run instrumented tests and coverage gate uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a with: api-level: 36 arch: x86_64 target: default disable-animations: true - script: ./gradlew :app:connectedDebugAndroidTest --no-daemon --stacktrace --console=plain + script: > + ./gradlew :app:connectedDebugAndroidTest :app:jacocoAndroidTestVerification + -PandroidTestCoverage=true + -PandroidTestMinCoverage=80 + -PandroidTestMinBranchCoverage=50 + --no-daemon --stacktrace --console=plain - name: Upload instrumented test reports if: failure() @@ -173,6 +178,8 @@ jobs: with: name: instrumented-test-reports path: | + app/build/outputs/code_coverage/debugAndroidTest/connected/ app/build/outputs/androidTest-results/connected/ + app/build/reports/jacoco/jacocoAndroidTestReport/ app/build/reports/androidTests/connected/ if-no-files-found: ignore From dad58a5d74a3562c9ed8af42ec60cc50c1c5e4b6 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 4 Jun 2026 12:18:05 +0300 Subject: [PATCH 30/38] Cover draft attachment limit validation --- .../draft/SendConversationDraftImplTest.kt | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt index 4400adae0..c1a7f9b03 100644 --- a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraftImplTest.kt @@ -1,5 +1,6 @@ package com.android.messaging.domain.conversation.usecase.draft +import android.net.Uri import app.cash.turbine.test import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft @@ -11,12 +12,14 @@ import com.android.messaging.data.subscription.repository.SubscriptionsRepositor import com.android.messaging.datamodel.action.InsertNewMessageAction import com.android.messaging.datamodel.data.ConversationParticipantsData import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.domain.conversation.usecase.draft.exception.BlankConversationIdException import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationRecipientsNotLoadedException import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException import com.android.messaging.domain.conversation.usecase.draft.exception.DraftDispatchFailedException import com.android.messaging.domain.conversation.usecase.draft.exception.EmptyConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.MessageLimitExceededException import com.android.messaging.domain.conversation.usecase.draft.exception.MissingSelfPhoneNumberForGroupMmsException import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException @@ -398,6 +401,40 @@ class SendConversationDraftImplTest { } } + @Test + fun invoke_throwsWhenMappedMessageExceedsAttachmentLimit() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messageData = createMessageDataWithAttachments(attachmentCount = 2) + val subscriptionsRepository = createSubscriptionsRepositoryMock(attachmentLimit = 1) + val useCase = createUseCase( + subscriptionsRepository = subscriptionsRepository, + mapper = createConversationDraftMessageDataMapperMock( + messageToReturn = messageData, + ), + ) + + val exception = collectFailure( + useCase.invoke( + conversationId = CONVERSATION_ID, + draft = ConversationDraft( + messageText = "Hello", + ), + ), + ) + + assertEquals(MessageLimitExceededException::class.java, exception.javaClass) + verify(exactly = 0) { + InsertNewMessageAction.insertNewMessage(any()) + } + verify(exactly = 0) { + InsertNewMessageAction.insertNewMessage( + any(), + any(), + ) + } + } + } + @Test fun invoke_locksDefaultSelfMessageToSystemDefaultSubscription() { runTest(context = mainDispatcherRule.testDispatcher) { @@ -542,9 +579,11 @@ class SendConversationDraftImplTest { return repository } - private fun createSubscriptionsRepositoryMock(): SubscriptionsRepository { + private fun createSubscriptionsRepositoryMock( + attachmentLimit: Int = Int.MAX_VALUE, + ): SubscriptionsRepository { val repository = mockk(relaxed = true) - every { repository.resolveAttachmentLimit() } returns Int.MAX_VALUE + every { repository.resolveAttachmentLimit() } returns attachmentLimit return repository } @@ -610,4 +649,24 @@ class SendConversationDraftImplTest { "Hello", ) } + + private fun createMessageDataWithAttachments(attachmentCount: Int): MessageData { + return MessageData.createDraftMmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + "", + ).apply { + repeat(attachmentCount) { index -> + addPart( + MessagePartData.createMediaMessagePart( + "image/jpeg", + Uri.parse("content://media/image/$index"), + 640, + 480, + ), + ) + } + } + } } From d82fcc9d95400dcca670c0114f1284c9c82cf747 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 4 Jun 2026 12:18:54 +0300 Subject: [PATCH 31/38] Cover conversation segment counter --- ...nversationComposerUiStateMapperImplTest.kt | 214 ++++++++++++++++++ .../composer/ui/ConversationComposeBarTest.kt | 84 +++++++ .../composer/ui/ConversationComposeBar.kt | 3 +- 3 files changed, 299 insertions(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt index e2294c79a..6ad99596d 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt @@ -5,16 +5,23 @@ import com.android.messaging.data.conversation.model.metadata.ConversationCompos import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.datamodel.MessageTextStats import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.sms.MmsConfig import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.composer.model.ConversationSegmentCounterUiState import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.mockkConstructor import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkConstructor import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import org.junit.After import org.junit.Assert.assertEquals @@ -40,10 +47,20 @@ internal class ConversationComposerUiStateMapperImplTest { every { mmsConfig.smsToMmsTextThreshold } returns -1 mockkStatic(MmsConfig::class) every { MmsConfig.get(any()) } returns mmsConfig + + mockkConstructor(MessageTextStats::class) + every { + anyConstructed().updateMessageTextStats(any(), any()) + } just runs + every { anyConstructed().numMessagesToBeSent } returns 1 + every { + anyConstructed().codePointsRemainingInCurrentMessage + } returns Int.MAX_VALUE } @After fun tearDown() { + unmockkConstructor(MessageTextStats::class) unmockkStatic(MmsConfig::class) } @@ -184,6 +201,170 @@ internal class ConversationComposerUiStateMapperImplTest { assertEquals(ConversationDraftSendProtocol.SMS, uiState.sendProtocol) } + @Test + fun map_hidesSegmentCounterWhenSmsMessageIsNotNearBoundary() { + every { anyConstructed().numMessagesToBeSent } returns 1 + every { + anyConstructed().codePointsRemainingInCurrentMessage + } returns 11 + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertNull(uiState.segmentCounter) + } + + @Test + fun map_showsSingleSegmentCounterWhenSmsMessageIsNearBoundary() { + val messageText = "Almost full" + every { anyConstructed().numMessagesToBeSent } returns 1 + every { + anyConstructed().codePointsRemainingInCurrentMessage + } returns 10 + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = messageText, + selfParticipantId = "sub-b", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf( + createSubscription( + selfParticipantId = "sub-a", + subId = FIRST_SUB_ID, + slotId = 1, + ), + createSubscription( + selfParticipantId = "sub-b", + subId = SECOND_SUB_ID, + slotId = 2, + ), + ), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = FIRST_SUB_ID, + ) + + assertEquals( + ConversationSegmentCounterUiState( + codePointsRemainingInCurrentMessage = 10, + messageCount = 1, + ), + uiState.segmentCounter, + ) + verify(exactly = 1) { + anyConstructed().updateMessageTextStats( + SECOND_SUB_ID, + messageText, + ) + } + } + + @Test + fun map_showsMultiSegmentCounterWhenSmsMessageSpansMultipleMessages() { + every { anyConstructed().numMessagesToBeSent } returns 3 + every { + anyConstructed().codePointsRemainingInCurrentMessage + } returns 82 + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Long message", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertEquals( + ConversationSegmentCounterUiState( + codePointsRemainingInCurrentMessage = 82, + messageCount = 3, + ), + uiState.segmentCounter, + ) + } + + @Test + fun map_hidesSegmentCounterForMmsDraft() { + every { anyConstructed().numMessagesToBeSent } returns 3 + every { + anyConstructed().codePointsRemainingInCurrentMessage + } returns 10 + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Long message", + ), + sendProtocol = ConversationDraftSendProtocol.MMS, + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertNull(uiState.segmentCounter) + } + + @Test + fun map_usesDefaultSelfSubIdForSegmentCounterWhenNoSubscriptionIsSelected() { + every { anyConstructed().numMessagesToBeSent } returns 1 + every { + anyConstructed().codePointsRemainingInCurrentMessage + } returns 10 + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Almost full", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertEquals( + ConversationSegmentCounterUiState( + codePointsRemainingInCurrentMessage = 10, + messageCount = 1, + ), + uiState.segmentCounter, + ) + verify(exactly = 1) { + anyConstructed().updateMessageTextStats( + ParticipantData.DEFAULT_SELF_SUB_ID, + "Almost full", + ) + } + } + @Test fun map_withoutDraftSelfParticipant_usesDefaultSmsSubscription() { val firstSubscription = firstSubscription() @@ -333,6 +514,39 @@ internal class ConversationComposerUiStateMapperImplTest { assertTrue(uiState.simSelector.isAvailable) } + @Test + fun map_fallsBackToFirstSubscriptionWhenDraftSelfParticipantIdDoesNotMatch() { + val firstSubscription = createSubscription( + selfParticipantId = "sub-a", + subId = FIRST_SUB_ID, + slotId = 1, + ) + val subscriptions = persistentListOf( + firstSubscription, + createSubscription( + selfParticipantId = "sub-b", + subId = SECOND_SUB_ID, + slotId = 2, + ), + ) + + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState(), + draftState = ConversationDraftState( + draft = ConversationDraft( + selfParticipantId = "non-existent", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = subscriptions, + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ) + + assertEquals(firstSubscription, uiState.simSelector.selectedSubscription) + } + @Test fun map_leavesSimSelectorUnavailableForSingleOrEmptySubscriptionList() { val emptyUiState = mapper.map( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt index f0f4bee49..c36987a70 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.click import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule @@ -43,6 +44,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_MEDIA_MENU_ import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_MMS_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEGMENT_COUNTER_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_TEXT_FIELD_TEST_TAG import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase @@ -127,6 +129,78 @@ class ConversationComposeBarTest { .assertCountEquals(expectedSize = 0) } + @Test + fun singleSegmentCounter_showsRemainingCharactersDescription() { + val expectedDescription = targetContext.resources.getQuantityString( + R.plurals.conversation_segment_counter_single_content_description, + 1, + 1, + ) + + setContent( + messageText = "Hello", + segmentCounter = segmentCounterState( + codePointsRemainingInCurrentMessage = 1, + messageCount = 1, + ), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEGMENT_COUNTER_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals("1") + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.ContentDescription, + listOf(expectedDescription), + ), + ) + } + + @Test + fun multiSegmentCounter_showsRemainingCharactersAndMessageCountDescription() { + val expectedDescription = targetContext.resources.getQuantityString( + R.plurals.conversation_segment_counter_content_description, + 7, + 7, + 3, + ) + + setContent( + messageText = "Long message", + segmentCounter = segmentCounterState( + codePointsRemainingInCurrentMessage = 7, + messageCount = 3, + ), + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEGMENT_COUNTER_TEST_TAG) + .assertIsDisplayed() + .assertTextEquals("7/3") + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.ContentDescription, + listOf(expectedDescription), + ), + ) + } + + @Test + fun activeRecording_hidesSegmentCounter() { + setContent( + audioRecording = recordingAudioState(), + messageText = "", + subjectText = "", + segmentCounter = segmentCounterState(), + shouldShowRecordAction = true, + ) + + composeTestRule + .onAllNodesWithTag(CONVERSATION_SEGMENT_COUNTER_TEST_TAG) + .assertCountEquals(expectedSize = 0) + } + @Test fun enabledState_andCallbacks_areWiredCorrectly() { var messageText = "" @@ -809,6 +883,16 @@ class ConversationComposeBarTest { return hapticFeedback } + private fun segmentCounterState( + codePointsRemainingInCurrentMessage: Int = 1, + messageCount: Int = 1, + ): ConversationSegmentCounterUiState { + return ConversationSegmentCounterUiState( + codePointsRemainingInCurrentMessage = codePointsRemainingInCurrentMessage, + messageCount = messageCount, + ) + } + private fun recordingAudioState( durationMillis: Long = 0L, isLocked: Boolean = false, diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index 10619f22a..116ed99e8 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag @@ -641,7 +640,7 @@ private fun SegmentCounterIndicator( Text( modifier = Modifier .padding(bottom = 4.dp) - .clearAndSetSemantics { + .semantics { testTag = CONVERSATION_SEGMENT_COUNTER_TEST_TAG contentDescription = accessibilityDescription }, From 366fc91446d655b54decd7e7e1f47a986de7d4bc Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 4 Jun 2026 12:36:27 +0300 Subject: [PATCH 32/38] Upgrade mockk --- gradle/libs.versions.toml | 2 +- gradle/verification-metadata.xml | 192 +++++++++++++++---------------- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f4b9d26f..9a15a5cd3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ recyclerview = "1.4.0" junit4 = "4.13.2" -mockk = "1.14.9" +mockk = "1.14.11" robolectric = "4.16.1" turbine = "1.2.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f0b211f25..c23f7fb56 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -5516,102 +5516,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -8886,5 +8790,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From ac41ebbb9e5bb7cda8038942c748c52b8cceaf62 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 9 Jun 2026 22:47:48 +0300 Subject: [PATCH 33/38] Update pinned deps hashes --- gradle/verification-metadata.xml | 5281 +++++++++++++++++++++++++++++- 1 file changed, 5110 insertions(+), 171 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c23f7fb56..81d0e6363 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -19,10 +19,21 @@ + + + + + + + + + + + @@ -34,6 +45,17 @@ + + + + + + + + + + + @@ -45,6 +67,17 @@ + + + + + + + + + + + @@ -63,6 +96,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -81,11 +138,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -94,23 +212,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -118,6 +271,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -128,6 +313,9 @@ + + + @@ -153,11 +341,11 @@ + + + - - - @@ -176,6 +364,14 @@ + + + + + + + + @@ -186,6 +382,9 @@ + + + @@ -211,6 +410,9 @@ + + + @@ -456,11 +658,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -469,9 +702,6 @@ - - - @@ -505,6 +735,9 @@ + + + @@ -525,7 +758,21 @@ + + + + + + + + + + + + + + @@ -533,6 +780,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -551,6 +820,9 @@ + + + @@ -558,6 +830,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -575,7 +877,21 @@ + + + + + + + + + + + + + + @@ -583,6 +899,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -600,7 +960,21 @@ + + + + + + + + + + + + + + @@ -609,9 +983,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -631,6 +1033,9 @@ + + + @@ -656,14 +1061,23 @@ + + + + + + + + + @@ -686,14 +1100,23 @@ + + + + + + + + + @@ -701,6 +1124,17 @@ + + + + + + + + + + + @@ -715,7 +1149,21 @@ + + + + + + + + + + + + + + @@ -740,7 +1188,21 @@ + + + + + + + + + + + + + + @@ -748,15 +1210,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -776,6 +1316,9 @@ + + + @@ -783,6 +1326,17 @@ + + + + + + + + + + + @@ -801,6 +1355,9 @@ + + + @@ -825,7 +1382,21 @@ + + + + + + + + + + + + + + @@ -833,6 +1404,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -850,7 +1476,29 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -858,20 +1506,103 @@ - - - + + + + + + + + + - - - + + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -880,7 +1611,21 @@ + + + + + + + + + + + + + + @@ -888,6 +1633,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -902,7 +1669,21 @@ + + + + + + + + + + + + + + @@ -910,6 +1691,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -928,9 +1731,15 @@ + + + + + + @@ -942,14 +1751,23 @@ + + + + + + + + + @@ -958,6 +1776,9 @@ + + + @@ -972,8 +1793,25 @@ + + + + + + + + + + + + + + + + + @@ -981,6 +1819,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -999,9 +1859,15 @@ + + + + + + @@ -1013,14 +1879,23 @@ + + + + + + + + + @@ -1029,11 +1904,17 @@ + + + + + + @@ -1058,7 +1939,21 @@ + + + + + + + + + + + + + + @@ -1066,6 +1961,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1083,7 +2000,21 @@ + + + + + + + + + + + + + + @@ -1091,6 +2022,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1137,6 +2112,9 @@ + + + @@ -1162,6 +2140,9 @@ + + + @@ -1174,6 +2155,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1184,6 +2197,25 @@ + + + + + + + + + + + + + + + + + + + @@ -1202,10 +2234,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1224,6 +2299,14 @@ + + + + + + + + @@ -1234,6 +2317,25 @@ + + + + + + + + + + + + + + + + + + + @@ -1260,6 +2362,22 @@ + + + + + + + + + + + + + + + + @@ -1376,6 +2494,14 @@ + + + + + + + + @@ -1386,6 +2512,9 @@ + + + @@ -1405,6 +2534,9 @@ + + + @@ -1416,6 +2548,9 @@ + + + @@ -1441,6 +2576,9 @@ + + + @@ -1456,10 +2594,29 @@ + + + + + + + + + + + + + + + + + + + @@ -1471,6 +2628,9 @@ + + + @@ -1487,12 +2647,12 @@ - - - + + + @@ -1573,11 +2733,17 @@ + + + + + + @@ -1585,15 +2751,38 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -1605,11 +2794,31 @@ + + + + + + + + + + + + + + + + + + + + @@ -1625,6 +2834,17 @@ + + + + + + + + + + + @@ -1639,7 +2859,18 @@ + + + + + + + + + + + @@ -1659,17 +2890,20 @@ - - - + + + + + + @@ -1692,16 +2926,25 @@ + + + + + + + + + @@ -1713,6 +2956,9 @@ + + + @@ -1728,6 +2974,14 @@ + + + + + + + + @@ -1742,6 +2996,14 @@ + + + + + + + + @@ -1760,6 +3022,9 @@ + + + @@ -1776,8 +3041,22 @@ + + + + + + + + + + + + + + @@ -1785,10 +3064,21 @@ + + + + + + + + + + + @@ -1800,11 +3090,20 @@ + + + + + + + + + @@ -1821,6 +3120,9 @@ + + + @@ -1828,14 +3130,36 @@ - - - + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + @@ -1853,10 +3177,24 @@ + + + + + + + + + + + + + + @@ -1872,7 +3210,18 @@ + + + + + + + + + + + @@ -1887,11 +3236,17 @@ + + + + + + @@ -1903,11 +3258,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1924,6 +3310,9 @@ + + + @@ -1931,6 +3320,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1962,7 +3373,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1985,6 +3423,9 @@ + + + @@ -1996,11 +3437,17 @@ + + + + + + @@ -2012,6 +3459,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2105,7 +3577,21 @@ + + + + + + + + + + + + + + @@ -2131,6 +3617,9 @@ + + + @@ -2156,11 +3645,20 @@ + + + + + + + + + @@ -2186,11 +3684,20 @@ + + + + + + + + + @@ -2199,12 +3706,6 @@ - - - - - - @@ -2227,6 +3728,9 @@ + + + @@ -2266,6 +3770,9 @@ + + + @@ -2357,6 +3864,14 @@ + + + + + + + + @@ -2367,14 +3882,17 @@ + + + - - - + + + @@ -2390,6 +3908,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2418,6 +3968,14 @@ + + + + + + + + @@ -2428,13 +3986,36 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -2459,12 +4040,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2490,17 +4102,20 @@ - - - + + + + + + @@ -2534,6 +4149,9 @@ + + + @@ -2549,6 +4167,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2560,6 +4214,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -2582,6 +4258,20 @@ + + + + + + + + + + + + + + @@ -2604,6 +4294,20 @@ + + + + + + + + + + + + + + @@ -2615,6 +4319,17 @@ + + + + + + + + + + + @@ -2626,6 +4341,20 @@ + + + + + + + + + + + + + + @@ -2637,6 +4366,20 @@ + + + + + + + + + + + + + + @@ -2658,6 +4401,9 @@ + + + @@ -2669,6 +4415,17 @@ + + + + + + + + + + + @@ -2693,12 +4450,12 @@ - - - + + + @@ -2726,12 +4483,12 @@ - - - + + + @@ -2812,14 +4569,17 @@ + + + - - - + + + @@ -2839,6 +4599,9 @@ + + + @@ -2864,9 +4627,18 @@ + + + + + + + + + @@ -2875,11 +4647,26 @@ + + + + + + + + + + + + + + + @@ -2888,6 +4675,12 @@ + + + + + + @@ -2986,6 +4779,9 @@ + + + @@ -3070,18 +4866,52 @@ - - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3091,8 +4921,14 @@ + + + + + + @@ -3102,6 +4938,9 @@ + + + @@ -3126,6 +4965,9 @@ + + + @@ -3135,8 +4977,14 @@ + + + + + + @@ -3146,8 +4994,14 @@ + + + + + + @@ -3157,6 +5011,9 @@ + + + @@ -3167,6 +5024,9 @@ + + + @@ -3176,8 +5036,14 @@ + + + + + + @@ -3187,8 +5053,14 @@ + + + + + + @@ -3198,8 +5070,14 @@ + + + + + + @@ -3209,8 +5087,14 @@ + + + + + + @@ -3220,6 +5104,9 @@ + + + @@ -3231,6 +5118,9 @@ + + + @@ -3242,6 +5132,9 @@ + + + @@ -3255,6 +5148,9 @@ + + + @@ -3263,6 +5159,9 @@ + + + @@ -3271,6 +5170,9 @@ + + + @@ -3279,6 +5181,9 @@ + + + @@ -3298,6 +5203,9 @@ + + + @@ -3306,6 +5214,9 @@ + + + @@ -3314,6 +5225,9 @@ + + + @@ -3322,6 +5236,9 @@ + + + @@ -3352,14 +5269,26 @@ + + + + + + + + + + + + @@ -3368,14 +5297,26 @@ + + + + + + + + + + + + @@ -3384,14 +5325,26 @@ + + + + + + + + + + + + @@ -3400,14 +5353,26 @@ + + + + + + + + + + + + @@ -3416,14 +5381,26 @@ + + + + + + + + + + + + @@ -3432,14 +5409,26 @@ + + + + + + + + + + + + @@ -3448,14 +5437,26 @@ + + + + + + + + + + + + @@ -3464,14 +5465,26 @@ + + + + + + + + + + + + @@ -3480,14 +5493,26 @@ + + + + + + + + + + + + @@ -3497,29 +5522,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3638,8 +5696,14 @@ + + + + + + @@ -3651,6 +5715,9 @@ + + + @@ -3662,6 +5729,9 @@ + + + @@ -3683,6 +5753,12 @@ + + + + + + @@ -3696,6 +5772,12 @@ + + + + + + @@ -3718,6 +5800,9 @@ + + + @@ -3757,6 +5842,11 @@ + + + + + @@ -3799,6 +5889,17 @@ + + + + + + + + + + + @@ -3823,12 +5924,23 @@ + + + + + + + + + + + @@ -3837,6 +5949,9 @@ + + + @@ -3848,6 +5963,9 @@ + + + @@ -3859,6 +5977,12 @@ + + + + + + @@ -3867,6 +5991,12 @@ + + + + + + @@ -3886,6 +6016,12 @@ + + + + + + @@ -3902,6 +6038,12 @@ + + + + + + @@ -3909,6 +6051,17 @@ + + + + + + + + + + + @@ -3924,6 +6077,9 @@ + + + @@ -3949,6 +6105,12 @@ + + + + + + @@ -3985,6 +6147,14 @@ + + + + + + + + @@ -3993,15 +6163,38 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -4011,8 +6204,25 @@ + + + + + + + + + + + + + + + + + @@ -4022,8 +6232,28 @@ + + + + + + + + + + + + + + + + + + + + @@ -4033,6 +6263,9 @@ + + + @@ -4048,7 +6281,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4068,6 +6329,9 @@ + + + @@ -4092,6 +6356,17 @@ + + + + + + + + + + + @@ -4120,6 +6395,16 @@ + + + + + + + + + + @@ -4140,6 +6425,11 @@ + + + + + @@ -4156,6 +6446,9 @@ + + + @@ -4167,6 +6460,12 @@ + + + + + + @@ -4221,7 +6520,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4232,15 +6589,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4250,8 +6647,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4262,21 +6696,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4287,7 +6756,17 @@ - + + + + + + + + + + + @@ -4314,7 +6793,21 @@ + + + + + + + + + + + + + + @@ -4372,6 +6865,16 @@ + + + + + + + + + + @@ -4392,6 +6895,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4406,12 +6942,35 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -4420,6 +6979,9 @@ + + + @@ -4431,6 +6993,9 @@ + + + @@ -4442,6 +7007,12 @@ + + + + + + @@ -4450,6 +7021,12 @@ + + + + + + @@ -4476,6 +7053,16 @@ + + + + + + + + + + @@ -4486,6 +7073,11 @@ + + + + + @@ -4497,6 +7089,9 @@ + + + @@ -4505,6 +7100,9 @@ + + + @@ -4513,6 +7111,9 @@ + + + @@ -4521,6 +7122,9 @@ + + + @@ -4529,6 +7133,9 @@ + + + @@ -4537,6 +7144,9 @@ + + + @@ -4545,6 +7155,12 @@ + + + + + + @@ -4558,6 +7174,9 @@ + + + @@ -4628,128 +7247,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4760,7 +7520,24 @@ + + + + + + + + + + + + + + + + + @@ -4772,12 +7549,21 @@ + + + + + + + + + @@ -4797,6 +7583,12 @@ + + + + + + @@ -4805,11 +7597,23 @@ + + + + + + + + + + + + @@ -4886,7 +7690,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4898,6 +7727,9 @@ + + + @@ -4923,30 +7755,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4956,6 +7818,9 @@ + + + @@ -4964,6 +7829,9 @@ + + + @@ -4972,158 +7840,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5131,16 +8173,37 @@ + + + + + + + + + + + + + + + + + + + + + @@ -5150,14 +8213,26 @@ + + + + + + + + + + + + @@ -5165,6 +8240,12 @@ + + + + + + @@ -5187,6 +8268,12 @@ + + + + + + @@ -5209,6 +8296,12 @@ + + + + + + @@ -5231,6 +8324,12 @@ + + + + + + @@ -5253,8 +8352,14 @@ - - + + + + + + + + @@ -5275,6 +8380,12 @@ + + + + + + @@ -5297,14 +8408,32 @@ + + + + + + + + + + + + + + + + + + @@ -5312,6 +8441,9 @@ + + + @@ -5321,6 +8453,9 @@ + + + @@ -5336,16 +8471,37 @@ + + + + + + + + + + + + + + + + + + + + + @@ -5355,8 +8511,17 @@ + + + + + + + + + @@ -5365,6 +8530,9 @@ + + + @@ -5376,6 +8544,9 @@ + + + @@ -5384,6 +8555,12 @@ + + + + + + @@ -5392,6 +8569,9 @@ + + + @@ -5403,6 +8583,12 @@ + + + + + + @@ -5411,6 +8597,9 @@ + + + @@ -5422,6 +8611,12 @@ + + + + + + @@ -5430,6 +8625,9 @@ + + + @@ -5440,7 +8638,24 @@ + + + + + + + + + + + + + + + + + @@ -5449,6 +8664,9 @@ + + + @@ -5460,6 +8678,12 @@ + + + + + + @@ -5468,6 +8692,9 @@ + + + @@ -5479,6 +8706,12 @@ + + + + + + @@ -5486,7 +8719,24 @@ + + + + + + + + + + + + + + + + + @@ -5495,6 +8745,9 @@ + + + @@ -5506,6 +8759,9 @@ + + + @@ -5516,7 +8772,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5528,6 +8964,9 @@ + + + @@ -5539,6 +8978,9 @@ + + + @@ -5550,6 +8992,9 @@ + + + @@ -5561,6 +9006,9 @@ + + + @@ -5572,6 +9020,9 @@ + + + @@ -5583,6 +9034,9 @@ + + + @@ -5594,6 +9048,9 @@ + + + @@ -5605,6 +9062,9 @@ + + + @@ -5616,6 +9076,9 @@ + + + @@ -5627,6 +9090,9 @@ + + + @@ -5638,6 +9104,9 @@ + + + @@ -5649,6 +9118,9 @@ + + + @@ -5660,6 +9132,9 @@ + + + @@ -5671,6 +9146,9 @@ + + + @@ -5682,6 +9160,9 @@ + + + @@ -5703,6 +9184,9 @@ + + + @@ -5714,6 +9198,9 @@ + + + @@ -5725,6 +9212,9 @@ + + + @@ -5736,6 +9226,9 @@ + + + @@ -5747,6 +9240,9 @@ + + + @@ -5758,6 +9254,9 @@ + + + @@ -5769,6 +9268,12 @@ + + + + + + @@ -5777,6 +9282,12 @@ + + + + + + @@ -5785,6 +9296,9 @@ + + + @@ -5794,8 +9308,14 @@ + + + + + + @@ -5805,6 +9325,9 @@ + + + @@ -5962,6 +9485,12 @@ + + + + + + @@ -5970,6 +9499,9 @@ + + + @@ -5999,6 +9531,11 @@ + + + + + @@ -6025,6 +9562,9 @@ + + + @@ -6036,6 +9576,9 @@ + + + @@ -6051,6 +9594,16 @@ + + + + + + + + + + @@ -6066,47 +9619,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + @@ -6141,6 +9657,11 @@ + + + + + @@ -6156,6 +9677,17 @@ + + + + + + + + + + + @@ -6185,6 +9717,9 @@ + + + @@ -6252,6 +9787,12 @@ + + + + + + @@ -6274,11 +9815,20 @@ + + + + + + + + + @@ -6288,6 +9838,9 @@ + + + @@ -6302,6 +9855,9 @@ + + + @@ -6316,8 +9872,14 @@ + + + + + + @@ -6329,6 +9891,9 @@ + + + @@ -6340,6 +9905,9 @@ + + + @@ -6385,6 +9953,12 @@ + + + + + + @@ -6446,9 +10020,6 @@ - - - @@ -6503,6 +10074,20 @@ + + + + + + + + + + + + + + @@ -6536,15 +10121,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6583,7 +10244,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6591,7 +10272,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6599,7 +10300,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6607,7 +10328,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6615,7 +10356,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6623,7 +10384,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6632,6 +10413,12 @@ + + + + + + @@ -6640,6 +10427,12 @@ + + + + + + @@ -6648,6 +10441,12 @@ + + + + + + @@ -6656,6 +10455,12 @@ + + + + + + @@ -6664,6 +10469,12 @@ + + + + + + @@ -6672,6 +10483,12 @@ + + + + + + @@ -6680,6 +10497,12 @@ + + + + + + @@ -6687,7 +10510,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6695,7 +10538,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -6704,6 +10567,12 @@ + + + + + + @@ -6712,6 +10581,12 @@ + + + + + + @@ -6720,6 +10595,12 @@ + + + + + + @@ -6728,6 +10609,12 @@ + + + + + + @@ -6736,6 +10623,12 @@ + + + + + + @@ -6744,6 +10637,12 @@ + + + + + + @@ -6763,6 +10662,9 @@ + + + @@ -6773,9 +10675,6 @@ - - - @@ -6784,6 +10683,9 @@ + + + @@ -6795,20 +10697,35 @@ + + + + + + + + + + + + + + + @@ -6817,43 +10734,40 @@ - - - - - - + + + + + + + + + - - - - - - @@ -6877,9 +10791,6 @@ - - - @@ -6888,6 +10799,9 @@ + + + @@ -6899,9 +10813,6 @@ - - - @@ -6910,6 +10821,9 @@ + + + @@ -6929,6 +10843,12 @@ + + + + + + @@ -6945,6 +10865,12 @@ + + + + + + @@ -6961,6 +10887,9 @@ + + + @@ -6972,6 +10901,12 @@ + + + + + + @@ -6980,6 +10915,12 @@ + + + + + + @@ -6996,6 +10937,9 @@ + + + @@ -7007,9 +10951,6 @@ - - - @@ -7018,6 +10959,9 @@ + + + @@ -7029,6 +10973,12 @@ + + + + + + @@ -7037,9 +10987,6 @@ - - - @@ -7048,6 +10995,9 @@ + + + @@ -7059,6 +11009,12 @@ + + + + + + @@ -7075,6 +11031,9 @@ + + + @@ -7085,38 +11044,52 @@ - - - + + + + + + + + + + + + + + - - - + + + + + + + + + - - - @@ -7125,6 +11098,9 @@ + + + @@ -7135,38 +11111,52 @@ - - - + + + + + + + + + + + + + + - - - + + + + + + + + + - - - @@ -7175,6 +11165,9 @@ + + + @@ -7184,11 +11177,11 @@ + + + - - - @@ -7197,6 +11190,9 @@ + + + @@ -7207,6 +11203,14 @@ + + + + + + + + @@ -7232,9 +11236,6 @@ - - - @@ -7243,6 +11244,9 @@ + + + @@ -7262,6 +11266,12 @@ + + + + + + @@ -7278,9 +11288,6 @@ - - - @@ -7289,6 +11296,9 @@ + + + @@ -7300,6 +11310,12 @@ + + + + + + @@ -7322,6 +11338,12 @@ + + + + + + @@ -7329,7 +11351,24 @@ + + + + + + + + + + + + + + + + + @@ -7338,6 +11377,12 @@ + + + + + + @@ -7360,6 +11405,9 @@ + + + @@ -7371,6 +11419,9 @@ + + + @@ -7382,15 +11433,9 @@ - - - - - - @@ -7404,6 +11449,12 @@ + + + + + + @@ -7420,6 +11471,9 @@ + + + @@ -7463,20 +11517,35 @@ + + + + + + + + + + + + + + + @@ -7484,7 +11553,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7492,7 +11677,24 @@ + + + + + + + + + + + + + + + + + @@ -7500,23 +11702,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7526,8 +11861,14 @@ + + + + + + @@ -7537,8 +11878,14 @@ + + + + + + @@ -7548,8 +11895,28 @@ + + + + + + + + + + + + + + + + + + + + @@ -7559,8 +11926,14 @@ + + + + + + @@ -7574,7 +11947,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7582,7 +12005,24 @@ + + + + + + + + + + + + + + + + + @@ -7594,16 +12034,25 @@ + + + + + + + + + @@ -7628,6 +12077,12 @@ + + + + + + @@ -7664,6 +12119,12 @@ + + + + + + @@ -7671,6 +12132,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7686,6 +12180,12 @@ + + + + + + @@ -7722,6 +12222,12 @@ + + + + + + @@ -7730,9 +12236,6 @@ - - - @@ -7741,6 +12244,9 @@ + + + @@ -7752,9 +12258,6 @@ - - - @@ -7763,6 +12266,9 @@ + + + @@ -7774,9 +12280,6 @@ - - - @@ -7785,6 +12288,9 @@ + + + @@ -7796,9 +12302,6 @@ - - - @@ -7807,6 +12310,9 @@ + + + @@ -7833,11 +12339,20 @@ + + + + + + + + + @@ -7846,14 +12361,26 @@ + + + + + + + + + + + + @@ -7868,6 +12395,12 @@ + + + + + + @@ -7876,6 +12409,9 @@ + + + @@ -7889,7 +12425,24 @@ + + + + + + + + + + + + + + + + + @@ -7916,6 +12469,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7952,7 +12536,21 @@ + + + + + + + + + + + + + + @@ -7960,22 +12558,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8002,16 +12643,31 @@ + + + + + + + + + + + + + + + @@ -8021,16 +12677,28 @@ + + + + + + + + + + + + @@ -8040,18 +12708,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8061,21 +12758,41 @@ + + + + + + + + + + + + + + + + + + + + @@ -8084,11 +12801,31 @@ + + + + + + + + + + + + + + + + + + + + @@ -8103,6 +12840,9 @@ + + + @@ -8112,13 +12852,25 @@ + + + + + + + + + + + + @@ -8128,8 +12880,14 @@ + + + + + + @@ -8139,6 +12897,9 @@ + + + @@ -8233,11 +12994,27 @@ + + + + + + + + + + + + + + + + @@ -8253,6 +13030,9 @@ + + + @@ -8264,6 +13044,12 @@ + + + + + + @@ -8272,6 +13058,9 @@ + + + @@ -8282,6 +13071,22 @@ + + + + + + + + + + + + + + + + @@ -8297,6 +13102,9 @@ + + + @@ -8322,6 +13130,9 @@ + + + @@ -8333,6 +13144,12 @@ + + + + + + @@ -8340,7 +13157,21 @@ + + + + + + + + + + + + + + @@ -8352,28 +13183,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8384,12 +13242,21 @@ + + + + + + + + + @@ -8406,44 +13273,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8460,14 +13372,26 @@ + + + + + + + + + + + + @@ -8479,6 +13403,12 @@ + + + + + + @@ -8497,6 +13427,12 @@ + + + + + + @@ -8520,6 +13456,9 @@ + + + From 85c671c212bd3adf4ee4f4a611d953e52492c053 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 9 Jun 2026 22:48:59 +0300 Subject: [PATCH 34/38] Cache AVD snapshot for tests --- .github/workflows/build.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c443c7394..5fd0ba7ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -158,12 +158,35 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Restore AVD cache + id: avd-cache + uses: actions/cache@v4 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-36-x86_64-default + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a + with: + api-level: 36 + arch: x86_64 + target: default + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + - name: Run instrumented tests and coverage gate uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a with: api-level: 36 arch: x86_64 target: default + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true script: > ./gradlew :app:connectedDebugAndroidTest :app:jacocoAndroidTestVerification From e2006e5985771074ab88d6f2433a69129b394a80 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 9 Jun 2026 22:49:11 +0300 Subject: [PATCH 35/38] Address tests review comments --- .../common/excluded-class-patterns.txt | 2 +- .../common/test/helpers/ShellCommandHelper.kt | 78 +++++++++++++++---- ...ionVCardAttachmentUiModelMapperImplTest.kt | 3 +- ...nversationComposerUiStateMapperImplTest.kt | 33 -------- .../ConversationMetadataDelegateImplTest.kt | 12 +++ .../composer/ui/ConversationComposeBar.kt | 6 +- 6 files changed, 80 insertions(+), 54 deletions(-) rename app/src/test/kotlin/com/android/messaging/ui/conversation/{messages => attachment}/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt (95%) diff --git a/app/jacoco-rules/common/excluded-class-patterns.txt b/app/jacoco-rules/common/excluded-class-patterns.txt index 8d82fe879..2b6def2d2 100644 --- a/app/jacoco-rules/common/excluded-class-patterns.txt +++ b/app/jacoco-rules/common/excluded-class-patterns.txt @@ -15,5 +15,5 @@ # Kotlin compiler-generated classes. **/*$DefaultImpls* **/*$invokeSuspend$* -**/*$serializer +**/*$$serializer.class **/*$$inlined$* diff --git a/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt b/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt index d0c531f64..24214e568 100644 --- a/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt +++ b/app/src/androidTest/kotlin/com/android/common/test/helpers/ShellCommandHelper.kt @@ -10,12 +10,17 @@ object ShellCommandHelper { fun setupSmsDefaultRole(): List { val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName - cancelScheduledSmsDefaultRoleRestore(packageName = packageName) - + val persistedRoleHolders = readPersistedSmsRoleHolders(packageName = packageName) val currentRoleHolders = getSmsRoleHolders() - val previousRoleHolders = originalSmsRoleHolders ?: currentRoleHolders.also { roleHolders -> - originalSmsRoleHolders = roleHolders - } + val previousRoleHolders = originalSmsRoleHolders + ?: (persistedRoleHolders ?: currentRoleHolders).also { roleHolders -> + originalSmsRoleHolders = roleHolders + } + cancelScheduledSmsDefaultRoleRestore( + packageName = packageName, + previousRoleHolders = previousRoleHolders, + ) + if (packageName !in currentRoleHolders) { executeCheckedShellCommand( command = "cmd role add-role-holder $SMS_ROLE_NAME $packageName", @@ -35,17 +40,58 @@ object ShellCommandHelper { scheduleSmsDefaultRoleRestore(previousRoleHolders = previousRoleHolders) } - private fun cancelScheduledSmsDefaultRoleRestore(packageName: String) { + private fun cancelScheduledSmsDefaultRoleRestore( + packageName: String, + previousRoleHolders: List, + ) { smsRoleRestoreGeneration += 1 + writeSmsRoleRestoreState( + generationFilePath = smsRoleRestoreGenerationFilePath(packageName = packageName), + generation = smsRoleRestoreGeneration, + previousRoleHolders = previousRoleHolders, + failureMessage = "Failed to cancel pending SMS default role restore", + ) + } + + // State file format: first line is the cancellation generation, remaining lines are + // the original role holders to restore. + // Persisting the holders lets a later instrumentation run that starts before the delayed + // restore fires recover the device's true original SMS app instead of capturing the + // test package as the original holder. + private fun readPersistedSmsRoleHolders(packageName: String): List? { + val generationFilePath = smsRoleRestoreGenerationFilePath(packageName = packageName) + val stateFileContent = executeShellCommand( + command = "sh -c ${shellSingleQuoted(value = "cat $generationFilePath 2>/dev/null")}", + ) + val stateFileLines = stateFileContent + .lineSequence() + .map { line -> line.trim() } + .filter { line -> line.isNotEmpty() } + .toList() + + return stateFileLines + .takeIf { it.isNotEmpty() } + ?.drop(1) + } + + private fun writeSmsRoleRestoreState( + generationFilePath: String, + generation: Int, + previousRoleHolders: List, + failureMessage: String, + ) { + val stateFileContent = (listOf(generation.toString()) + previousRoleHolders) + .joinToString(separator = "\n") + executeCheckedShellCommand( command = "sh -c ${ shellSingleQuoted( - value = "printf %s $smsRoleRestoreGeneration > ${ - smsRoleRestoreGenerationFilePath(packageName = packageName) - }", + value = "printf %s ${ + shellSingleQuoted(value = stateFileContent) + } > $generationFilePath", ) }", - failureMessage = "Failed to cancel pending SMS default role restore", + failureMessage = failureMessage, ) } @@ -59,12 +105,10 @@ object ShellCommandHelper { generationFilePath = generationFilePath, ) - executeCheckedShellCommand( - command = "sh -c ${ - shellSingleQuoted( - value = "printf %s $smsRoleRestoreGeneration > $generationFilePath", - ) - }", + writeSmsRoleRestoreState( + generationFilePath = generationFilePath, + generation = smsRoleRestoreGeneration, + previousRoleHolders = previousRoleHolders, failureMessage = "Failed to prepare SMS default role restore", ) executeCheckedShellCommand( @@ -86,7 +130,7 @@ object ShellCommandHelper { return "{" + " sleep $SMS_ROLE_RESTORE_DELAY_SECONDS;" + - " if [ \"\$(cat ${shellWord( + " if [ \"\$(sed -n 1p ${shellWord( value = generationFilePath )} 2>/dev/null)\" = \"$generation\" ]; then" + " cmd role clear-role-holders $SMS_ROLE_NAME;" + diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt similarity index 95% rename from app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt rename to app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt index bf190f573..d73e9bc10 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapperImplTest.kt @@ -1,9 +1,8 @@ -package com.android.messaging.ui.conversation.messages.mapper +package com.android.messaging.ui.conversation.attachment.mapper import com.android.messaging.R import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType -import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapperImpl import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt index 6ad99596d..a5bfb9e78 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt @@ -514,39 +514,6 @@ internal class ConversationComposerUiStateMapperImplTest { assertTrue(uiState.simSelector.isAvailable) } - @Test - fun map_fallsBackToFirstSubscriptionWhenDraftSelfParticipantIdDoesNotMatch() { - val firstSubscription = createSubscription( - selfParticipantId = "sub-a", - subId = FIRST_SUB_ID, - slotId = 1, - ) - val subscriptions = persistentListOf( - firstSubscription, - createSubscription( - selfParticipantId = "sub-b", - subId = SECOND_SUB_ID, - slotId = 2, - ), - ) - - val uiState = mapper.map( - audioRecording = ConversationAudioRecordingUiState(), - draftState = ConversationDraftState( - draft = ConversationDraft( - selfParticipantId = "non-existent", - ), - ), - attachments = persistentListOf(), - composerAvailability = ConversationComposerAvailability.Editable, - subscriptions = subscriptions, - areSubscriptionsLoaded = true, - defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, - ) - - assertEquals(firstSubscription, uiState.simSelector.selectedSubscription) - } - @Test fun map_leavesSimSelectorUnavailableForSingleOrEmptySubscriptionList() { val emptyUiState = mapper.map( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt index 3a26093de..839dcfef1 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt @@ -216,6 +216,18 @@ class ConversationMetadataDelegateImplTest { false, harness.delegate.isDeleteConversationConfirmationVisible.value, ) + verify(exactly = 0) { + harness.conversationsRepository.archiveConversation( + conversationId = any(), + ) + harness.conversationsRepository.unarchiveConversation( + conversationId = any(), + ) + harness.conversationsRepository.deleteConversation( + conversationId = any(), + cutoffTimestamp = any(), + ) + } } finally { harness.cancel() } diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index 116ed99e8..4913e1b9b 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -40,9 +40,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -640,9 +643,10 @@ private fun SegmentCounterIndicator( Text( modifier = Modifier .padding(bottom = 4.dp) - .semantics { + .clearAndSetSemantics { testTag = CONVERSATION_SEGMENT_COUNTER_TEST_TAG contentDescription = accessibilityDescription + text = AnnotatedString(text = displayText) }, text = displayText, style = MaterialTheme.typography.labelSmall, From a11c548af94cefc6619e9fce5890d905c1e932e9 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 11 Jun 2026 20:23:10 +0300 Subject: [PATCH 36/38] Update deps pins --- gradle/verification-metadata.xml | 5079 +++++++++++++++++++++++++++--- 1 file changed, 4673 insertions(+), 406 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 81d0e6363..373be907d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -5,6 +5,14 @@ false + + + + + + + + @@ -27,6 +35,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -71,6 +103,14 @@ + + + + + + + + @@ -134,6 +174,22 @@ + + + + + + + + + + + + + + + + @@ -150,6 +206,14 @@ + + + + + + + + @@ -206,6 +270,9 @@ + + + @@ -217,6 +284,9 @@ + + + @@ -228,6 +298,9 @@ + + + @@ -239,6 +312,9 @@ + + + @@ -250,6 +326,9 @@ + + + @@ -260,7 +339,24 @@ + + + + + + + + + + + + + + + + + @@ -428,6 +524,14 @@ + + + + + + + + @@ -657,7 +761,24 @@ + + + + + + + + + + + + + + + + + @@ -669,6 +790,9 @@ + + + @@ -680,6 +804,9 @@ + + + @@ -691,6 +818,9 @@ + + + @@ -759,6 +889,12 @@ + + + + + + @@ -770,6 +906,12 @@ + + + + + + @@ -780,7 +922,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -791,7 +970,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -819,7 +1021,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -838,7 +1063,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -850,6 +1129,12 @@ + + + + + + @@ -889,6 +1174,12 @@ + + + + + + @@ -899,7 +1190,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -911,6 +1222,12 @@ + + + + + + @@ -922,6 +1239,12 @@ + + + + + + @@ -933,6 +1256,12 @@ + + + + + + @@ -961,6 +1290,12 @@ + + + + + + @@ -972,6 +1307,12 @@ + + + + + + @@ -982,7 +1323,24 @@ + + + + + + + + + + + + + + + + + @@ -993,7 +1351,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1005,6 +1403,12 @@ + + + + + + @@ -1032,7 +1436,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -1075,6 +1499,9 @@ + + + @@ -1114,6 +1541,9 @@ + + + @@ -1124,7 +1554,24 @@ + + + + + + + + + + + + + + + + + @@ -1150,6 +1597,12 @@ + + + + + + @@ -1161,6 +1614,12 @@ + + + + + + @@ -1188,7 +1647,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1200,6 +1689,12 @@ + + + + + + @@ -1210,7 +1705,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1222,6 +1742,12 @@ + + + + + + @@ -1232,7 +1758,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1244,6 +1827,12 @@ + + + + + + @@ -1255,6 +1844,12 @@ + + + + + + @@ -1266,6 +1861,12 @@ + + + + + + @@ -1276,7 +1877,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -1288,6 +1912,12 @@ + + + + + + @@ -1315,7 +1945,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -1327,7 +1980,13 @@ - + + + + + + + @@ -1337,6 +1996,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1354,7 +2081,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -1382,7 +2132,29 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1394,6 +2166,12 @@ + + + + + + @@ -1404,7 +2182,18 @@ + + + + + + + + + + + @@ -1415,7 +2204,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -1426,7 +2235,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1437,7 +2320,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -1449,6 +2355,12 @@ + + + + + + @@ -1476,6 +2388,14 @@ + + + + + + + + @@ -1485,6 +2405,12 @@ + + + + + + @@ -1496,6 +2422,12 @@ + + + + + + @@ -1506,7 +2438,18 @@ + + + + + + + + + + + @@ -1517,7 +2460,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -1528,7 +2491,30 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -1540,6 +2526,12 @@ + + + + + + @@ -1551,6 +2543,12 @@ + + + + + + @@ -1562,6 +2560,12 @@ + + + + + + @@ -1573,6 +2577,12 @@ + + + + + + @@ -1584,6 +2594,12 @@ + + + + + + @@ -1611,7 +2627,26 @@ + + + + + + + + + + + + + + + + + + + @@ -1623,6 +2658,9 @@ + + + @@ -1633,7 +2671,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1644,7 +2749,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1669,7 +2833,29 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1681,6 +2867,12 @@ + + + + + + @@ -1691,47 +2883,203 @@ - - - + + + - - + + - - + + + + + + + + + + + + + - - - + + + - - + + - - + + + + + - - - + + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1759,6 +3107,9 @@ + + + @@ -1797,7 +3148,29 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1809,6 +3182,12 @@ + + + + + + @@ -1819,7 +3198,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1830,7 +3302,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1859,6 +3388,12 @@ + + + + + + @@ -1887,6 +3422,9 @@ + + + @@ -1912,6 +3450,12 @@ + + + + + + @@ -1939,7 +3483,29 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1951,6 +3517,12 @@ + + + + + + @@ -1961,7 +3533,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1972,7 +3620,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2000,7 +3722,26 @@ + + + + + + + + + + + + + + + + + + + @@ -2012,6 +3753,9 @@ + + + @@ -2022,18 +3766,74 @@ - - - - - - + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2045,6 +3845,9 @@ + + + @@ -2055,7 +3858,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2258,6 +4106,14 @@ + + + + + + + + @@ -2282,6 +4138,14 @@ + + + + + + + + @@ -2299,6 +4163,14 @@ + + + + + + + + @@ -2378,6 +4250,14 @@ + + + + + + + + @@ -2610,6 +4490,14 @@ + + + + + + + + @@ -2732,6 +4620,14 @@ + + + + + + + + @@ -2741,6 +4637,9 @@ + + + @@ -2751,6 +4650,17 @@ + + + + + + + + + + + @@ -2762,6 +4672,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -2798,7 +4730,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2809,7 +4800,24 @@ + + + + + + + + + + + + + + + + + @@ -2834,6 +4842,17 @@ + + + + + + + + + + + @@ -2900,6 +4919,14 @@ + + + + + + + + @@ -2922,6 +4949,14 @@ + + + + + + + + @@ -2960,6 +4995,14 @@ + + + + + + + + @@ -2974,6 +5017,14 @@ + + + + + + + + @@ -2982,6 +5033,14 @@ + + + + + + + + @@ -3054,6 +5113,9 @@ + + + @@ -3064,6 +5126,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3094,7 +5180,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3120,6 +5237,12 @@ + + + + + + @@ -3131,6 +5254,12 @@ + + + + + + @@ -3142,6 +5271,12 @@ + + + + + + @@ -3177,6 +5312,22 @@ + + + + + + + + + + + + + + + + @@ -3186,6 +5337,9 @@ + + + @@ -3219,6 +5373,9 @@ + + + @@ -3232,6 +5389,22 @@ + + + + + + + + + + + + + + + + @@ -3262,7 +5435,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3274,6 +5478,9 @@ + + + @@ -3285,6 +5492,9 @@ + + + @@ -3310,6 +5520,12 @@ + + + + + + @@ -3321,6 +5537,12 @@ + + + + + + @@ -3332,6 +5554,12 @@ + + + + + + @@ -3373,6 +5601,22 @@ + + + + + + + + + + + + + + + + @@ -3398,6 +5642,9 @@ + + + @@ -3423,6 +5670,12 @@ + + + + + + @@ -3433,6 +5686,22 @@ + + + + + + + + + + + + + + + + @@ -3464,6 +5733,12 @@ + + + + + + @@ -3475,6 +5750,12 @@ + + + + + + @@ -3578,6 +5859,12 @@ + + + + + + @@ -3589,6 +5876,12 @@ + + + + + + @@ -3617,6 +5910,12 @@ + + + + + + @@ -3645,6 +5944,12 @@ + + + + + + @@ -3656,6 +5961,12 @@ + + + + + + @@ -3684,6 +5995,12 @@ + + + + + + @@ -3695,6 +6012,12 @@ + + + + + + @@ -3728,6 +6051,12 @@ + + + + + + @@ -3770,6 +6099,12 @@ + + + + + + @@ -3864,11 +6199,35 @@ - - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3968,6 +6327,14 @@ + + + + + + + + @@ -3990,7 +6357,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4002,6 +6409,12 @@ + + + + + + @@ -4013,6 +6426,12 @@ + + + + + + @@ -4041,6 +6460,12 @@ + + + + + + @@ -4052,6 +6477,12 @@ + + + + + + @@ -4063,6 +6494,12 @@ + + + + + + @@ -4074,6 +6511,12 @@ + + + + + + @@ -4101,6 +6544,22 @@ + + + + + + + + + + + + + + + + @@ -4599,6 +7058,12 @@ + + + + + + @@ -5557,6 +8022,20 @@ + + + + + + + + + + + + + + @@ -5574,6 +8053,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5642,6 +8163,22 @@ + + + + + + + + + + + + + + + + @@ -6148,6 +8685,12 @@ + + + + + + @@ -6156,6 +8699,12 @@ + + + + + + @@ -6164,6 +8713,12 @@ + + + + + + @@ -6209,6 +8764,12 @@ + + + + + + @@ -6303,6 +8864,17 @@ + + + + + + + + + + + @@ -6381,6 +8953,17 @@ + + + + + + + + + + + @@ -6405,6 +8988,11 @@ + + + + + @@ -6435,6 +9023,11 @@ + + + + + @@ -6865,6 +9458,11 @@ + + + + + @@ -6895,6 +9493,17 @@ + + + + + + + + + + + @@ -7053,6 +9662,11 @@ + + + + + @@ -7690,6 +10304,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8458,6 +11099,12 @@ + + + + + + @@ -9561,6 +12208,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -9619,6 +12311,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -9716,6 +12570,17 @@ + + + + + + + + + + + @@ -9842,6 +12707,20 @@ + + + + + + + + + + + + + + @@ -9876,6 +12755,22 @@ + + + + + + + + + + + + + + + + @@ -9986,6 +12881,22 @@ + + + + + + + + + + + + + + + + @@ -10020,12 +12931,21 @@ + + + + + + + + + @@ -10398,6 +13318,20 @@ + + + + + + + + + + + + + + @@ -10412,7 +13346,21 @@ - + + + + + + + + + + + + + + + @@ -10524,6 +13472,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -10580,6 +13556,20 @@ + + + + + + + + + + + + + + @@ -10622,6 +13612,11 @@ + + + + + @@ -10656,6 +13651,12 @@ + + + + + + @@ -10667,6 +13668,12 @@ + + + + + + @@ -10674,7 +13681,24 @@ + + + + + + + + + + + + + + + + + @@ -10697,6 +13721,24 @@ + + + + + + + + + + + + + + + + + + @@ -10706,6 +13748,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -10733,15 +13799,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -10751,6 +13954,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -10765,14 +13992,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -10790,7 +14080,24 @@ + + + + + + + + + + + + + + + + + @@ -10812,7 +14119,24 @@ + + + + + + + + + + + + + + + + + @@ -10835,6 +14159,12 @@ + + + + + + @@ -10857,6 +14187,12 @@ + + + + + + @@ -10879,6 +14215,12 @@ + + + + + + @@ -10929,6 +14271,12 @@ + + + + + + @@ -10950,7 +14298,24 @@ + + + + + + + + + + + + + + + + + @@ -10987,6 +14352,12 @@ + + + + + + @@ -11023,6 +14394,12 @@ + + + + + + @@ -11045,28 +14422,151 @@ - + - - + + - - + + - - + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11076,6 +14576,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -11089,7 +14613,24 @@ + + + + + + + + + + + + + + + + + @@ -11112,6 +14653,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11126,14 +14715,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11143,6 +14807,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -11156,13 +14844,36 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -11181,7 +14892,24 @@ + + + + + + + + + + + + + + + + + @@ -11203,6 +14931,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11228,6 +15018,12 @@ + + + + + + @@ -11235,7 +15031,24 @@ + + + + + + + + + + + + + + + + + @@ -11258,6 +15071,12 @@ + + + + + + @@ -11280,6 +15099,12 @@ + + + + + + @@ -11287,7 +15112,24 @@ + + + + + + + + + + + + + + + + + @@ -11433,14 +15275,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11463,6 +15368,12 @@ + + + + + + @@ -11485,6 +15396,12 @@ + + + + + + @@ -11493,6 +15410,12 @@ + + + + + + @@ -11501,6 +15424,12 @@ + + + + + + @@ -11509,6 +15438,12 @@ + + + + + + @@ -11517,6 +15452,24 @@ + + + + + + + + + + + + + + + + + + @@ -11526,6 +15479,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -11575,6 +15552,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11597,6 +15640,17 @@ + + + + + + + + + + + @@ -11703,6 +15757,9 @@ + + + @@ -11717,6 +15774,9 @@ + + + @@ -11731,6 +15791,9 @@ + + + @@ -11745,6 +15808,9 @@ + + + @@ -11758,7 +15824,27 @@ + + + + + + + + + + + + + + + + + + + + @@ -11773,6 +15859,9 @@ + + + @@ -11787,6 +15876,9 @@ + + + @@ -11801,6 +15893,9 @@ + + + @@ -11815,6 +15910,9 @@ + + + @@ -11832,6 +15930,9 @@ + + + @@ -11849,6 +15950,9 @@ + + + @@ -11866,6 +15970,9 @@ + + + @@ -11883,6 +15990,9 @@ + + + @@ -11900,6 +16010,9 @@ + + + @@ -11914,6 +16027,9 @@ + + + @@ -11931,6 +16047,9 @@ + + + @@ -11947,6 +16066,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11980,6 +16165,17 @@ + + + + + + + + + + + @@ -12030,6 +16226,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12132,6 +16353,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12207,6 +16461,17 @@ + + + + + + + + + + + @@ -12235,7 +16500,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12258,6 +16568,12 @@ + + + + + + @@ -12280,6 +16596,12 @@ + + + + + + @@ -12301,7 +16623,24 @@ + + + + + + + + + + + + + + + + + @@ -12338,6 +16677,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12349,6 +16754,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12456,21 +16894,91 @@ - - - + + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12511,11 +17019,21 @@ + + + + + + + + + + @@ -12569,6 +17087,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12580,6 +17145,17 @@ + + + + + + + + + + + @@ -12602,6 +17178,17 @@ + + + + + + + + + + + @@ -12884,6 +17471,20 @@ + + + + + + + + + + + + + + @@ -12953,6 +17554,14 @@ + + + + + + + + @@ -13483,347 +18092,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 1510d5b0366c5e003b2042731eb83e9703f7cbcf Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 11 Jun 2026 21:18:24 +0300 Subject: [PATCH 37/38] Fix tests after rebasing --- .../BaseConversationsRepositoryTest.kt | 2 + .../SubscriptionsRepositoryImplTest.kt | 4 ++ ...nDraftEditorDelegateStateProjectionTest.kt | 6 ++- .../NewChatViewModelSimSelectionTest.kt | 1 + .../BaseConversationViewModelTest.kt | 43 +++++++++++++------ 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt index fab067c0e..691b54217 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt @@ -5,6 +5,7 @@ import android.database.ContentObserver import android.database.Cursor import android.net.Uri import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.data.conversation.store.ConversationSelfIdStore import com.android.messaging.testutil.MainDispatcherRule import io.mockk.every import io.mockk.just @@ -30,6 +31,7 @@ internal abstract class BaseConversationsRepositoryTest { protected fun createRepository(): ConversationsRepositoryImpl { return ConversationsRepositoryImpl( contentResolver = contentResolver, + conversationSelfIdStore = mockk(relaxed = true), defaultDispatcher = mainDispatcherRule.testDispatcher, messagingDbDispatcher = mainDispatcherRule.testDispatcher, ) diff --git a/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt index 48672e3b5..0427d265c 100644 --- a/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/subscription/repository/SubscriptionsRepositoryImplTest.kt @@ -1,9 +1,11 @@ package com.android.messaging.data.subscription.repository import android.content.ContentResolver +import android.content.Context import android.database.ContentObserver import android.database.MatrixCursor import android.net.Uri +import android.telephony.SubscriptionManager import app.cash.turbine.test import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel import com.android.messaging.data.subscription.model.Subscription @@ -555,7 +557,9 @@ class SubscriptionsRepositoryImplTest { private fun createRepository(): SubscriptionsRepositoryImpl { return SubscriptionsRepositoryImpl( + context = mockk(), contentResolver = contentResolver, + subscriptionManager = mockk(), debugSimEmulationSource = emulationSource, defaultDispatcher = mainDispatcherRule.testDispatcher, messagingDbDispatcher = mainDispatcherRule.testDispatcher, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt index 017797ad9..2b9beeba9 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/conversationdrafteditordelegate/ConversationDraftEditorDelegateStateProjectionTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.composer.delegate.conversationdrafteditordelegate import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -33,7 +34,10 @@ internal class ConversationDraftEditorDelegateStateProjectionTest : fun onSelfParticipantIdChanged_reflectsParticipantWithoutPromotingToMms() { val delegate = loadedDelegate() - delegate.onSelfParticipantIdChanged(selfParticipantId = "self-2") + delegate.onSelfParticipantIdChanged( + conversationId = CONVERSATION_ID, + selfParticipantId = "self-2", + ) assertEquals("self-2", delegate.state.value.draft.selfParticipantId) assertEquals(ConversationDraftSendProtocol.SMS, delegate.state.value.sendProtocol) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt index 3f618bb60..5c71fa715 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/newchat/NewChatViewModelSimSelectionTest.kt @@ -170,6 +170,7 @@ internal class NewChatViewModelSimSelectionTest : BaseNewChatViewModelTest() { } } } + @Test fun simSelection_withStalePersistedSelection_fallsBackToDefaultSmsSubscription() { runTest(context = mainDispatcherRule.testDispatcher) { diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt index 0ae6071e3..c1160b866 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt @@ -5,8 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability -import com.android.messaging.data.subscription.model.Subscription -import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.data.subscription.repository.ConversationSimSelectionRepository +import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable @@ -18,10 +18,12 @@ import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRec import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationSubscriptionSelectionDelegate import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.composer.model.ConversationSubscriptionSelectionState import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegate @@ -75,8 +77,9 @@ internal abstract class BaseConversationViewModelTest { isEmergencyPhoneNumber: IsEmergencyPhoneNumber = IsEmergencyPhoneNumber { false }, composerUiStateMapper: ConversationComposerUiStateMapper = createComposerUiStateMapperMock(mappedUiState = ConversationComposerUiState()), - subscriptionsRepository: SubscriptionsRepository = - createSubscriptionsRepositoryMock(subscriptions = persistentListOf()), + subscriptionSelectionDelegate: ConversationSubscriptionSelectionDelegate = + createSubscriptionSelectionDelegateMock().mock, + simSelectionRepository: ConversationSimSelectionRepository = mockk(relaxed = true), ): ConversationViewModel { return ConversationViewModel( conversationAudioRecordingDelegate = audioRecordingDelegate, @@ -87,8 +90,9 @@ internal abstract class BaseConversationViewModelTest { conversationMediaPickerDelegate = mediaPickerDelegate, conversationMetadataDelegate = metadataDelegate, conversationFocusDelegate = focusDelegate, + conversationSubscriptionSelectionDelegate = subscriptionSelectionDelegate, conversationComposerUiStateMapper = composerUiStateMapper, - subscriptionsRepository = subscriptionsRepository, + simSelectionRepository = simSelectionRepository, canAddMoreConversationParticipants = canAddMoreConversationParticipants, createDefaultSmsRoleRequest = createDefaultSmsRoleRequest, isDeviceVoiceCapable = isDeviceVoiceCapable, @@ -171,14 +175,20 @@ internal abstract class BaseConversationViewModelTest { ) } - protected fun createSubscriptionsRepositoryMock( - subscriptions: ImmutableList, - ): SubscriptionsRepository { - val repository = mockk() - every { - repository.observeActiveSubscriptions() - } returns MutableStateFlow(subscriptions) - return repository + protected fun createSubscriptionSelectionDelegateMock( + state: ConversationSubscriptionSelectionState = ConversationSubscriptionSelectionState( + subscriptions = persistentListOf(), + areSubscriptionsLoaded = false, + defaultSmsSubscriptionId = ParticipantData.DEFAULT_SELF_SUB_ID, + ), + ): SubscriptionSelectionDelegateMock { + val stateFlow = MutableStateFlow(state) + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + return SubscriptionSelectionDelegateMock( + mock = mock, + stateFlow = stateFlow, + ) } protected fun createDraftDelegateMock(): DraftDelegateMock { @@ -335,7 +345,7 @@ internal abstract class BaseConversationViewModelTest { ): ConversationComposerUiStateMapper { val mapper = mockk() every { - mapper.map(any(), any(), any(), any(), any(), any()) + mapper.map(any(), any(), any(), any(), any(), any(), any()) } returns mappedUiState return mapper } @@ -400,6 +410,11 @@ internal abstract class BaseConversationViewModelTest { val bindCalls: List, ) + protected data class SubscriptionSelectionDelegateMock( + val mock: ConversationSubscriptionSelectionDelegate, + val stateFlow: MutableStateFlow, + ) + protected data class MediaPickerDelegateMock( val mock: ConversationMediaPickerDelegate, val effectsFlow: MutableSharedFlow, From d04b9cf27cf4458e8e7876a0d382761a87420a78 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 11 Jun 2026 22:22:48 +0300 Subject: [PATCH 38/38] Fix flaky participants list test --- .../screen/ParticipantsListTest.kt | 59 +++++++++++++++---- .../support/ConversationSettingsTestData.kt | 10 +++- .../ui/conversation/ConversationTestTags.kt | 4 ++ .../screen/ConversationSettingsScreen.kt | 8 +++ 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/ParticipantsListTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/ParticipantsListTest.kt index 2599a28d9..bcbc7be7c 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/ParticipantsListTest.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/ParticipantsListTest.kt @@ -1,21 +1,29 @@ package com.android.messaging.ui.conversationsettings.screen +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTouchInput import com.android.messaging.R +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.conversationSettingsParticipantRowTestTag import com.android.messaging.ui.conversationsettings.screen.model.ParticipantConversationSettingsAction as ParticipantAction import com.android.messaging.ui.conversationsettings.screen.support.ConversationSettingsTestBase import com.android.messaging.ui.conversationsettings.screen.support.FATHER_DESTINATION import com.android.messaging.ui.conversationsettings.screen.support.FATHER_NAME +import com.android.messaging.ui.conversationsettings.screen.support.FATHER_PARTICIPANT_ID import com.android.messaging.ui.conversationsettings.screen.support.MOTHER_DESTINATION import com.android.messaging.ui.conversationsettings.screen.support.MOTHER_NAME -import com.android.messaging.ui.conversationsettings.screen.support.TEST_DESTINATION +import com.android.messaging.ui.conversationsettings.screen.support.MOTHER_PARTICIPANT_ID +import com.android.messaging.ui.conversationsettings.screen.support.TEST_PARTICIPANT_ID import com.android.messaging.ui.conversationsettings.screen.support.groupState import com.android.messaging.ui.conversationsettings.screen.support.oneToOneState import io.mockk.verify @@ -39,29 +47,26 @@ internal class ParticipantsListTest : ConversationSettingsTestBase() { fun click_opensQuickActionsPopup_inGroup() { renderScreen(groupState()) - composeTestRule.onNodeWithText(MOTHER_NAME).performClick() + clickParticipantRow(participantId = MOTHER_PARTICIPANT_ID) - composeTestRule - .onNodeWithContentDescription(string(R.string.action_send_message)) - .assertIsDisplayed() + waitForQuickAction(actionResId = R.string.action_send_message) } @Test fun click_opensQuickActionsPopup_inOneToOne() { renderScreen(oneToOneState()) - composeTestRule.onNodeWithText(TEST_DESTINATION).performClick() + clickParticipantRow(participantId = TEST_PARTICIPANT_ID) - composeTestRule - .onNodeWithContentDescription(string(R.string.action_send_message)) - .assertIsDisplayed() + waitForQuickAction(actionResId = R.string.action_send_message) } @Test fun quickActions_messageClick_dispatchesParticipantPressed() { renderScreen(groupState()) - composeTestRule.onNodeWithText(MOTHER_NAME).performClick() + clickParticipantRow(participantId = MOTHER_PARTICIPANT_ID) + waitForQuickAction(actionResId = R.string.action_send_message) composeTestRule .onNodeWithContentDescription(string(R.string.action_send_message)) .performClick() @@ -77,7 +82,11 @@ internal class ParticipantsListTest : ConversationSettingsTestBase() { fun longPress_dispatchesParticipantLongPressed_withDetails() { renderScreen(groupState()) - composeTestRule.onNodeWithText(FATHER_NAME).performTouchInput { longClick() } + composeTestRule + .onNodeWithTag( + testTag = conversationSettingsParticipantRowTestTag(FATHER_PARTICIPANT_ID), + ) + .performTouchInput { longClick() } verify(exactly = 1) { screenModel.onAction( @@ -114,4 +123,32 @@ internal class ParticipantsListTest : ConversationSettingsTestBase() { .onNodeWithText(string(R.string.participant_list_title)) .assertDoesNotExist() } + + private fun clickParticipantRow(participantId: String) { + composeTestRule + .onNodeWithTag(testTag = conversationSettingsParticipantRowTestTag(participantId)) + .performScrollTo() + .assertIsDisplayed() + .performClick() + } + + @OptIn(ExperimentalTestApi::class) + private fun waitForQuickAction(actionResId: Int) { + val actionDescription = string(actionResId) + + composeTestRule.waitUntilAtLeastOneExists( + matcher = hasContentDescription(value = actionDescription), + timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS, + ) + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + runCatching { + composeTestRule + .onNodeWithContentDescription(label = actionDescription) + .assertIsDisplayed() + }.isSuccess + } + composeTestRule + .onNodeWithContentDescription(label = actionDescription) + .assertIsDisplayed() + } } diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/support/ConversationSettingsTestData.kt b/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/support/ConversationSettingsTestData.kt index 140ada926..bc93e380a 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/support/ConversationSettingsTestData.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/conversationsettings/screen/support/ConversationSettingsTestData.kt @@ -15,6 +15,10 @@ internal const val FINISH_RESULT_CODE = 1 internal const val MOTHER_NAME = "Mother" internal const val FATHER_NAME = "Father" +internal const val MOTHER_PARTICIPANT_ID = "mother" +internal const val FATHER_PARTICIPANT_ID = "father" +internal const val TEST_PARTICIPANT_ID = "test_participant" + internal const val ONE_TO_ONE_TITLE = MOTHER_NAME internal const val GROUP_TITLE = "Family" @@ -63,12 +67,12 @@ internal fun groupState(): ConversationSettingsUiState { conversationTitle = GROUP_TITLE, participants = persistentListOf( participant( - id = "mother", + id = MOTHER_PARTICIPANT_ID, displayName = MOTHER_NAME, displayDestination = MOTHER_DESTINATION, ), participant( - id = "father", + id = FATHER_PARTICIPANT_ID, displayName = FATHER_NAME, displayDestination = FATHER_DESTINATION, ), @@ -80,7 +84,7 @@ internal fun groupState(): ConversationSettingsUiState { } internal fun participant( - id: String = "test_participant", + id: String = TEST_PARTICIPANT_ID, displayName: String = MOTHER_NAME, displayDestination: String = TEST_DESTINATION, isBlocked: Boolean = false, diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index a48f010eb..2fd40780b 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -95,6 +95,10 @@ internal fun conversationMessageSelectionActionButtonTestTag(action: String): St return "conversation_message_selection_action_${action.lowercase()}" } +internal fun conversationSettingsParticipantRowTestTag(participantId: String): String { + return "conversation_settings_participant_row_$participantId" +} + internal fun conversationAttachmentPreviewItemTestTag(attachmentKey: String): String { return "conversation_attachment_preview_item_$attachmentKey" } diff --git a/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt b/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt index 813c6b5e7..00caba121 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -64,6 +65,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.android.messaging.R import com.android.messaging.ui.conversation.ConversationActivity +import com.android.messaging.ui.conversation.conversationSettingsParticipantRowTestTag import com.android.messaging.ui.conversationsettings.common.ConversationHeader import com.android.messaging.ui.conversationsettings.common.ConversationSettingsItem import com.android.messaging.ui.conversationsettings.common.ConversationSettingsTopAppBar @@ -611,6 +613,12 @@ private fun ParticipantsCard( onAction = { onAction(ParticipantAction.ParticipantActionPressed(destination)) }.takeIf { hasDestination && isGroup }, + modifier = Modifier + .testTag( + tag = conversationSettingsParticipantRowTestTag( + participant.id, + ), + ), ) } }