From 1e7c9b22561d45ebd973d42e704cc1cb95320815 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 16 Feb 2024 16:17:35 +0100 Subject: [PATCH 01/10] Remove warning in tests --- .../messages/impl/typing/TypingNotificationPresenterTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt index 51b642ea68..92acae4eea 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt @@ -178,7 +178,6 @@ class TypingNotificationPresenterTest { @Test fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest { - val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2) val room = FakeMatrixRoom() val presenter = createPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { From 367e4bdbdaf9bb77d1573588dfd78721f08889a9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 09:16:43 +0100 Subject: [PATCH 02/10] Introduce `fun aShowLocationState` to reduce boilerplate code. --- .../impl/show/ShowLocationStateProvider.kt | 87 ++++++------------- 1 file changed, 28 insertions(+), 59 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 84737c192b..dd0e67b1b8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -24,78 +24,47 @@ private const val APP_NAME = "ApplicationName" class ShowLocationStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), - description = null, - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, + aShowLocationState(), + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, ), - ShowLocationState( - ShowLocationState.Dialog.PermissionDenied, - Location(1.23, 2.34, 4f), - description = null, - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, ), - ShowLocationState( - ShowLocationState.Dialog.PermissionRationale, - Location(1.23, 2.34, 4f), - description = null, - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, - ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), - description = null, + aShowLocationState( hasLocationPermission = true, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), - description = null, + aShowLocationState( hasLocationPermission = true, isTrackMyLocation = true, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), + aShowLocationState( description = "My favourite place!", - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), + aShowLocationState( description = "For some reason I decided to to write a small essay that wraps at just two lines!", - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), - ShowLocationState( - ShowLocationState.Dialog.None, - Location(1.23, 2.34, 4f), + aShowLocationState( description = "For some reason I decided to write a small essay in the location description. " + "It is so long that it will wrap onto more than two lines!", - hasLocationPermission = false, - isTrackMyLocation = false, - appName = APP_NAME, - eventSink = {}, ), ) } + +fun aShowLocationState( + permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, + location: Location = Location(1.23, 2.34, 4f), + description: String? = null, + hasLocationPermission: Boolean = false, + isTrackMyLocation: Boolean = false, + appName: String = APP_NAME, + eventSink: (ShowLocationEvents) -> Unit = {}, +) = ShowLocationState( + permissionDialog = permissionDialog, + location = location, + description = description, + hasLocationPermission = hasLocationPermission, + isTrackMyLocation = isTrackMyLocation, + appName = appName, + eventSink = eventSink, +) From 3cbbde7c3e1b3e4944b12100c60b873d3b6c730d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 10:24:20 +0100 Subject: [PATCH 03/10] Add first tests for ShowLocationView --- features/location/impl/build.gradle.kts | 10 +- .../location/impl/show/ShowLocationView.kt | 8 +- .../impl/show/ShowLocationViewTest.kt | 156 ++++++++++++++++++ .../theme/components/FloatingActionButton.kt | 4 +- .../android/libraries/testtags/TestTags.kt | 5 + 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index dd265ee7f0..515843c7bb 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -22,6 +22,11 @@ plugins { android { namespace = "io.element.android.features.location.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -51,11 +56,14 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) - testImplementation(libs.test.truth) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index d603bd19ae..2f352d50d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -120,10 +120,14 @@ fun ShowLocationView( ) }, navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton( + onClick = onBackPressed, + ) }, actions = { - IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) { + IconButton( + onClick = { state.eventSink(ShowLocationEvents.Share) } + ) { Icon( imageVector = CompoundIcons.ShareAndroid(), contentDescription = stringResource(CommonStrings.action_share), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt new file mode 100644 index 0000000000..a66197e733 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.location.impl.show + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowLocationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `test back action`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setShowLocationView( + state = aShowLocationState( + eventSink = eventsRecorder + ), + onBackPressed = callback, + ) + rule.pressBack() + } + } + + @Test + fun `test share action`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + val shareContentDescription = rule.activity.getString(CommonStrings.action_share) + rule.onNodeWithContentDescription(shareContentDescription).performClick() + eventsRecorder.assertSingle(ShowLocationEvents.Share) + } + + @Test + fun `test fab click`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + val shareContentDescription = rule.activity.getString(CommonStrings.action_share) + rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() + eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) + } + + @Test + fun `when permission denied is displayed user can open the settings`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) + } + + @Test + fun `when permission denied is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + } + + @Test + fun `when permission rationale is displayed user can request permissions`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) + } + + @Test + fun `when permission rationale is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + eventSink = eventsRecorder + ), + onBackPressed = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + } +} + +private fun AndroidComposeTestRule.setShowLocationView( + state: ShowLocationState, + onBackPressed: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Simulate a LocalInspectionMode for MapboxMap + CompositionLocalProvider(LocalInspectionMode provides true) { + ShowLocationView( + state = state, + onBackPressed = onBackPressed, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt index c7d9f2344f..1d50e77ce4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag @Composable fun FloatingActionButton( @@ -48,7 +50,7 @@ fun FloatingActionButton( ) { androidx.compose.material3.FloatingActionButton( onClick = onClick, - modifier = modifier, + modifier = modifier.testTag(TestTags.floatingActionButton), shape = shape, containerColor = containerColor, contentColor = contentColor, diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index c2acd98bfe..7992ddee17 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -75,6 +75,11 @@ object TestTags { val dialogNegative = TestTag("dialog-negative") val dialogNeutral = TestTag("dialog-neutral") + /** + * Floating Action Button. + */ + val floatingActionButton = TestTag("floating-action-button") + /** * Timeline item. */ From f372fd27cf08b383a997cc90c89ff4b429de473e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 12:26:41 +0100 Subject: [PATCH 04/10] Add test for RoomListEvents.DismissRecoveryKeyPrompt. Also get the encryptionService from the matrixClient, instead of injecting it separately. --- .../roomlist/impl/RoomListPresenter.kt | 4 +- .../roomlist/impl/RoomListPresenterTests.kt | 44 ++++++++++++++++--- .../test/encryption/FakeEncryptionService.kt | 4 ++ .../android/samples/minimal/RoomListScreen.kt | 1 - 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 6edc533476..fa5cfd044a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -75,13 +75,14 @@ class RoomListPresenter @Inject constructor( private val inviteStateDataSource: InviteStateDataSource, private val leaveRoomPresenter: LeaveRoomPresenter, private val roomListDataSource: RoomListDataSource, - private val encryptionService: EncryptionService, private val featureFlagService: FeatureFlagService, private val indicatorService: IndicatorService, private val searchPresenter: Presenter, private val migrationScreenPresenter: MigrationScreenPresenter, private val sessionPreferencesStore: SessionPreferencesStore, ) : Presenter { + private val encryptionService: EncryptionService = client.encryptionService() + @Composable override fun present(): RoomListState { val coroutineScope = rememberCoroutineScope() @@ -139,7 +140,6 @@ class RoomListPresenter @Inject constructor( contextMenu.value = RoomListState.ContextMenu.Hidden } is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) - is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch { client.getRoom(event.roomId)?.use { room -> room.setIsFavorite(event.isFavorite) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 025caa3f96..6828821d68 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -42,13 +42,14 @@ import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampF import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter +import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.BackupState -import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.ReceiptType @@ -108,9 +109,12 @@ class RoomListPresenterTests { fun `present - show avatar indicator`() = runTest { val scope = CoroutineScope(coroutineContext + SupervisorJob()) val encryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient( + encryptionService = encryptionService, + ) val sessionVerificationService = FakeSessionVerificationService() val presenter = createRoomListPresenter( - encryptionService = encryptionService, + client = matrixClient, sessionVerificationService = sessionVerificationService, coroutineScope = scope ) @@ -255,6 +259,33 @@ class RoomListPresenterTests { } } + @Test + fun `present - handle DismissRecoveryKeyPrompt`() = runTest { + val encryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient( + encryptionService = encryptionService, + ) + val scope = CoroutineScope(context = coroutineContext + SupervisorJob()) + val presenter = createRoomListPresenter( + client = matrixClient, + coroutineScope = scope, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.displayRecoveryKeyPrompt).isFalse() + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + val nextState = awaitItem() + assertThat(nextState.displayRecoveryKeyPrompt).isTrue() + nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) + val finalState = awaitItem() + assertThat(finalState.displayRecoveryKeyPrompt).isFalse() + scope.cancel() + } + } + @Test fun `present - sets invite state`() = runTest { val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) @@ -506,8 +537,8 @@ class RoomListPresenterTests { givenFormat(A_FORMATTED_DATE) }, roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), - encryptionService: EncryptionService = FakeEncryptionService(), sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), coroutineScope: CoroutineScope, migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( matrixClient = client, @@ -531,12 +562,11 @@ class RoomListPresenterTests { notificationSettingsService = client.notificationSettingsService(), appScope = coroutineScope ), - encryptionService = encryptionService, - featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), + featureFlagService = featureFlagService, indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, - encryptionService = encryptionService, - featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), + encryptionService = client.encryptionService(), + featureFlagService = featureFlagService, ), migrationScreenPresenter = migrationScreenPresenter, searchPresenter = searchPresenter, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 945821f7f2..7593a5ba51 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -103,6 +103,10 @@ class FakeEncryptionService : EncryptionService { backupStateStateFlow.emit(state) } + suspend fun emitRecoveryState(state: RecoveryState) { + recoveryStateStateFlow.emit(state) + } + suspend fun emitEnableRecoveryProgress(state: EnableRecoveryProgress) { enableRecoveryProgressStateFlow.emit(state) } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 58981000f0..fe9d7d1202 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -101,7 +101,6 @@ class RoomListScreen( notificationSettingsService = matrixClient.notificationSettingsService(), appScope = Singleton.appScope ), - encryptionService = encryptionService, indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, encryptionService = encryptionService, From 1d10796625e7ac313f94a058ecb7007f98bc0b2c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 12:58:20 +0100 Subject: [PATCH 05/10] Add first tests for BlockUserDialogs --- features/roomdetails/impl/build.gradle.kts | 8 ++ .../impl/blockuser/BlockUserDialogs.kt | 10 +- .../details/RoomMemberDetailsStateProvider.kt | 40 +++++--- .../impl/blockuser/BlockUserDialogsTest.kt | 97 +++++++++++++++++++ 4 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 0f1a139f40..8f1cfdc2f0 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -23,6 +23,11 @@ plugins { android { namespace = "io.element.android.features.roomdetails.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -61,6 +66,7 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.test.mockk) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) @@ -70,6 +76,8 @@ dependencies { testImplementation(projects.tests.testutils) testImplementation(projects.features.leaveroom.test) testImplementation(projects.features.createroom.test) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) ksp(libs.showkase.processor) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt index ddbff5b6a9..be25a6228f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogs.kt @@ -55,7 +55,10 @@ fun BlockUserDialogs(state: RoomMemberDetailsState) { } @Composable -private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) { +private fun BlockConfirmationDialog( + onBlockAction: () -> Unit, + onDismiss: () -> Unit, +) { ConfirmationDialog( title = stringResource(R.string.screen_dm_details_block_user), content = stringResource(R.string.screen_dm_details_block_alert_description), @@ -66,7 +69,10 @@ private fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> } @Composable -private fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) { +private fun UnblockConfirmationDialog( + onUnblockAction: () -> Unit, + onDismiss: () -> Unit, +) { ConfirmationDialog( title = stringResource(R.string.screen_dm_details_unblock_user), content = stringResource(R.string.screen_dm_details_unblock_alert_description), diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt index ff4b9ee1b3..dfc208e047 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt @@ -19,28 +19,38 @@ package io.element.android.features.roomdetails.impl.members.details import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId open class RoomMemberDetailsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomMemberDetailsState(), - aRoomMemberDetailsState().copy(userName = null), - aRoomMemberDetailsState().copy(isBlocked = AsyncData.Success(true)), - aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block), - aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock), - aRoomMemberDetailsState().copy(isBlocked = AsyncData.Loading(true)), - aRoomMemberDetailsState().copy(startDmActionState = AsyncAction.Loading), + aRoomMemberDetailsState(userName = null), + aRoomMemberDetailsState(isBlocked = AsyncData.Success(true)), + aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block), + aRoomMemberDetailsState(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock), + aRoomMemberDetailsState(isBlocked = AsyncData.Loading(true)), + aRoomMemberDetailsState(startDmActionState = AsyncAction.Loading), // Add other states here ) } -fun aRoomMemberDetailsState() = RoomMemberDetailsState( - userId = "@daniel:domain.com", - userName = "Daniel", - avatarUrl = null, - isBlocked = AsyncData.Success(false), - startDmActionState = AsyncAction.Uninitialized, - displayConfirmationDialog = null, - isCurrentUser = false, - eventSink = {}, +fun aRoomMemberDetailsState( + userId: String = "@daniel:domain.com", + userName: String? = "Daniel", + avatarUrl: String? = null, + isBlocked: AsyncData = AsyncData.Success(false), + startDmActionState: AsyncAction = AsyncAction.Uninitialized, + displayConfirmationDialog: RoomMemberDetailsState.ConfirmationDialog? = null, + isCurrentUser: Boolean = false, + eventSink: (RoomMemberDetailsEvents) -> Unit = {}, +) = RoomMemberDetailsState( + userId = userId, + userName = userName, + avatarUrl = avatarUrl, + isBlocked = isBlocked, + startDmActionState = startDmActionState, + displayConfirmationDialog = displayConfirmationDialog, + isCurrentUser = isCurrentUser, + eventSink = eventSink, ) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt new file mode 100644 index 0000000000..7b86bd50ba --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.impl.blockuser + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomdetails.impl.R +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState +import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlockUserDialogsTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `confirm block user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(R.string.screen_dm_details_block_alert_action) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.BlockUser(false)) + } + + @Test + fun `cancel block user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog) + } + + @Test + fun `confirm unblock user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(R.string.screen_dm_details_unblock_alert_action) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.UnblockUser(false)) + } + + @Test + fun `cancel unblock user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aRoomMemberDetailsState( + displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog) + } +} + From 1734c43951d44176ce714d245dd1b68e50c4d237 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 13:15:56 +0100 Subject: [PATCH 06/10] Add test for `MessagesEvents.HandleAction(TimelineItemAction.EndPoll)` --- .../messages/impl/MessagesPresenterTest.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index e1236b805d..c1ce011cea 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -49,6 +49,7 @@ import io.element.android.features.messages.impl.voicemessages.timeline.FakeReda import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper @@ -98,6 +99,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -372,6 +374,22 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action end poll`() = runTest { + val endPollAction = FakeEndPollAction() + val presenter = createMessagesPresenter(endPollAction = endPollAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + endPollAction.verifyExecutionCount(0) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent()))) + delay(1) + endPollAction.verifyExecutionCount(1) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - handle action redact`() = runTest { val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) @@ -683,6 +701,7 @@ class MessagesPresenterTest { clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + endPollAction: EndPollAction = FakeEndPollAction(), ): MessagesPresenter { val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) @@ -721,7 +740,7 @@ class MessagesPresenterTest { encryptionService = FakeEncryptionService(), verificationService = FakeSessionVerificationService(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), - endPollAction = FakeEndPollAction(), + endPollAction = endPollAction, sendPollResponseAction = FakeSendPollResponseAction(), sessionPreferencesStore = sessionPreferencesStore, ) From 8961147844563484718558575eca68762cff2ec8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 13:28:18 +0100 Subject: [PATCH 07/10] Add test for `MessageComposerEvents.SendUri` --- .../textcomposer/MessageComposerPresenterTest.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index c189e28c13..5e980514cb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -87,10 +87,13 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import uniffi.wysiwyg_composer.MentionsState import java.io.File @Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) class MessageComposerPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -875,6 +878,19 @@ class MessageComposerPresenterTest { } } + @Test + fun `present - send uri`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.richTextEditorState.messageHtml) { state } + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri"))) + waitForPredicate { mediaPreProcessor.processCallCount == 1 } + } + } + @Test fun `present - handle typing notice event`() = runTest { val room = FakeMatrixRoom() From 99773bc7e0bcb83cdc52cae1df58576d0f3e2f04 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 13:32:50 +0100 Subject: [PATCH 08/10] Kover: ignore io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter --- plugins/src/main/kotlin/extension/KoverExtension.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/src/main/kotlin/extension/KoverExtension.kt b/plugins/src/main/kotlin/extension/KoverExtension.kt index 9185bfb1b0..20ee1f977f 100644 --- a/plugins/src/main/kotlin/extension/KoverExtension.kt +++ b/plugins/src/main/kotlin/extension/KoverExtension.kt @@ -85,6 +85,8 @@ fun Project.setupKover() { "*Presenter\$present\$*", // Forked from compose "io.element.android.libraries.designsystem.theme.components.bottomsheet.*", + // Test presenter + "io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter", ) annotatedBy( "androidx.compose.ui.tooling.preview.Preview", From 973aef4c729304c1e2b534867d785ead0ba44744 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 13:52:01 +0100 Subject: [PATCH 09/10] Add test for RoomListEvents.ToggleSearchResults. --- .../search/RoomListSearchStateProvider.kt | 3 +- .../roomlist/impl/RoomListPresenterTests.kt | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt index b846354b6f..ae722a4b04 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt @@ -38,9 +38,10 @@ fun aRoomListSearchState( isSearchActive: Boolean = false, query: String = "", results: ImmutableList = persistentListOf(), + eventSink: (RoomListSearchEvents) -> Unit = { }, ) = RoomListSearchState( isSearchActive = isSearchActive, query = query, results = results, - eventSink = { }, + eventSink = eventSink, ) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 6828821d68..53e890ee59 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -33,6 +33,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryF import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary +import io.element.android.features.roomlist.impl.search.RoomListSearchEvents import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.aRoomListSearchState import io.element.android.libraries.architecture.Presenter @@ -69,6 +70,7 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.testCoroutineDispatchers @@ -414,6 +416,40 @@ class RoomListPresenterTests { } } + @Test + fun `present - toggle search menu`() = runTest { + val eventRecorder = EventsRecorder() + val searchPresenter: Presenter = Presenter { + aRoomListSearchState( + eventSink = eventRecorder + ) + } + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val presenter = createRoomListPresenter( + coroutineScope = scope, + searchPresenter = searchPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + eventRecorder.assertEmpty() + initialState.eventSink(RoomListEvents.ToggleSearchResults) + eventRecorder.assertSingle( + RoomListSearchEvents.ToggleSearchVisibility + ) + initialState.eventSink(RoomListEvents.ToggleSearchResults) + eventRecorder.assertList( + listOf( + RoomListSearchEvents.ToggleSearchVisibility, + RoomListSearchEvents.ToggleSearchVisibility + ) + ) + scope.cancel() + } + } + @Test fun `present - change in notification settings updates the summary for decorations`() = runTest { val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY From 4fc9947a9e2f3ec0c019afb0a2224dfdb54eb775 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 19 Feb 2024 14:17:18 +0100 Subject: [PATCH 10/10] Remove extra new line. --- .../features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt index 7b86bd50ba..5683b88c3c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserDialogsTest.kt @@ -94,4 +94,3 @@ class BlockUserDialogsTest { eventsRecorder.assertSingle(RoomMemberDetailsEvents.ClearConfirmationDialog) } } -