From 872bf5a70f00651501c8566333c87f9e4378e303 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 22 Sep 2026 10:21:01 +0200 Subject: [PATCH] Let Claude update the tests. --- .../PreferencesAccountPresenterTest.kt | 198 +++++++++ .../account/PreferencesAccountViewTest.kt | 245 +++++++++++ .../AppDeveloperSettingsPageTest.kt | 12 + .../AppDeveloperSettingsPresenterTest.kt | 21 + .../location/LocationSettingsPresenterTest.kt | 71 ++++ .../impl/location/LocationSettingsViewTest.kt | 88 ++++ .../media/AdvancedSettingsPresenterTest.kt | 393 ------------------ .../impl/media/AdvancedSettingsViewTest.kt | 264 ------------ .../impl/media/MediaSettingsPresenterTest.kt | 114 +++++ .../impl/media/MediaSettingsViewTest.kt | 173 ++++++++ .../FakeMediaPreviewConfigStateStore.kt | 2 +- .../MediaPreviewConfigStateStoreTest.kt | 2 +- .../ModerationAndSafetyPresenterTest.kt | 131 ++++++ .../moderation/ModerationAndSafetyViewTest.kt | 153 +++++++ .../impl/root/PreferencesRootPresenterTest.kt | 230 +++------- .../impl/root/PreferencesRootViewTest.kt | 300 ++++--------- 16 files changed, 1352 insertions(+), 1045 deletions(-) create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt delete mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt delete mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt rename features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/{media => moderation}/FakeMediaPreviewConfigStateStore.kt (97%) rename features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/{media => moderation}/MediaPreviewConfigStateStoreTest.kt (99%) create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt new file mode 100644 index 0000000000..117fbe15fb --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.account + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.enterprise.test.FakeSessionEnterpriseService +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +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.indicator.api.IndicatorService +import io.element.android.libraries.indicator.test.FakeIndicatorService +import io.element.android.libraries.matrix.api.oauth.AccountManagementAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class PreferencesAccountPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val accountManagementUrlResult = lambdaRecorder> { action -> + Result.success("$action url") + } + val matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = accountManagementUrlResult, + ) + createPreferencesAccountPresenter( + matrixClient = matrixClient, + sessionEnterpriseService = FakeSessionEnterpriseService( + tweakMasUrlResult = { "tweaked $it" }, + ), + ).test { + val initialState = awaitItem() + assertThat(initialState.myUser).isEqualTo( + MatrixUser( + userId = matrixClient.sessionId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL + ) + ) + assertThat(initialState.showSecureBackupBadge).isFalse() + assertThat(initialState.accountManagementUrl).isNull() + assertThat(initialState.showLinkNewDevice).isFalse() + assertThat(initialState.nbOfBlockedUsers).isEqualTo(0) + assertThat(initialState.showBlockedUsersItem).isFalse() + assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState()) + assertThat(initialState.snackbarMessage).isNull() + val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last() + assertThat(loadedState.showSecureBackup).isFalse() + assertThat(loadedState.canDeactivateAccount).isTrue() + assertThat(loadedState.canReportBug).isTrue() + assertThat(loadedState.accountManagementUrl).isEqualTo("tweaked null url") + accountManagementUrlResult.assertions().isCalledOnce() + .with(value(null)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - cannot report bug`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ), + rageshakeFeatureAvailability = { flowOf(false) }, + ).test { + assertThat(awaitItem().canReportBug).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - number of blocked users`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)), + ), + ).test { + val state = consumeItemsUntilPredicate { it.nbOfBlockedUsers == 2 }.last() + assertThat(state.showBlockedUsersItem).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - secure backup badge`() = runTest { + val indicatorService = FakeIndicatorService() + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ), + indicatorService = indicatorService, + ).test { + assertThat(awaitItem().showSecureBackupBadge).isFalse() + indicatorService.setShowSettingChatBackupIndicator(true) + consumeItemsUntilPredicate { it.showSecureBackupBadge } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - secure backup is shown when the session does not need to be verified`() = runTest { + val sessionVerificationService = FakeSessionVerificationService() + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ), + sessionVerificationService = sessionVerificationService, + ).test { + // The session needs to be verified, so the secure backup entry is hidden. + consumeItemsUntilPredicate { !it.showSecureBackup } + sessionVerificationService.emitNeedsSessionVerification(false) + consumeItemsUntilPredicate { it.showSecureBackup } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - can deactivate account is false if the Matrix client say so`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { false }, + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + assertThat(awaitItem().canDeactivateAccount).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - link new device`() = runTest { + createPreferencesAccountPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.QrCodeLogin.key to true) + ), + ).test { + consumeItemsUntilPredicate { it.showLinkNewDevice } + cancelAndIgnoreRemainingEvents() + } + } + + private fun createPreferencesAccountPresenter( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) }, + indicatorService: IndicatorService = FakeIndicatorService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(), + ) = PreferencesAccountPresenter( + matrixClient = matrixClient, + sessionVerificationService = sessionVerificationService, + snackbarDispatcher = SnackbarDispatcher(), + indicatorService = indicatorService, + directLogoutPresenter = { aDirectLogoutState() }, + rageshakeFeatureAvailability = rageshakeFeatureAvailability, + featureFlagService = featureFlagService, + sessionEnterpriseService = sessionEnterpriseService, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt new file mode 100644 index 0000000000..903d56f39a --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountViewTest.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.account + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class PreferencesAccountViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes back callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onBackClick = callback, + ) + pressBack() + } + } + + @Test + fun `click on the avatar invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnceWithParam("anAvatarUrl") { callback -> + setView( + aPreferencesAccountState( + myUser = aMatrixUser(avatarUrl = "anAvatarUrl"), + ), + onAvatarClick = callback, + ) + onNodeWithContentDescription(activity!!.getString(CommonStrings.a11y_user_avatar)).performClick() + } + } + + @Test + fun `click on Edit profile invokes the expected callback`() = runAndroidComposeUiTest { + val user = aMatrixUser() + ensureCalledOnceWithParam(user) { callback -> + setView( + aPreferencesAccountState(myUser = user), + onEditProfileClick = callback, + ) + clickOn(R.string.screen_edit_profile_title) + } + } + + @Test + fun `click on Manage account invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnceWithParam("aUrl") { callback -> + setView( + aPreferencesAccountState(accountManagementUrl = "aUrl"), + onManageAccountClick = callback, + ) + clickOn(CommonStrings.action_manage_account_and_devices) + } + } + + @Test + fun `when accountManagementUrl is null, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(accountManagementUrl = null)) + onNodeWithText(activity!!.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist() + } + + @Test + fun `click on Link new devices invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(showLinkNewDevice = true), + onLinkNewDeviceClick = callback, + ) + clickOn(CommonStrings.common_link_new_device) + } + } + + @Test + fun `when showLinkNewDevice is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(showLinkNewDevice = false)) + onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() + } + + @Test + fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(nbOfBlockedUsers = 1), + onOpenBlockedUsers = callback, + ) + clickOn(CommonStrings.common_blocked_users) + } + } + + @Test + fun `when nbOfBlockedUsers is 0, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(nbOfBlockedUsers = 0)) + onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist() + } + + @Test + fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onOpenNotificationSettings = callback, + ) + clickOn(R.string.screen_notification_settings_title) + } + } + + @Test + fun `click on Encryption invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(showSecureBackup = true), + onSecureBackupClick = callback, + ) + val text = activity!!.getString(CommonStrings.common_encryption) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } + } + + @Test + fun `when showSecureBackup is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(showSecureBackup = false)) + onNodeWithText(activity!!.getString(CommonStrings.common_encryption)).assertDoesNotExist() + } + + @Test + fun `click on Moderation and safety invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onModerationAndSafetyClick = callback, + ) + val text = activity!!.getString(CommonStrings.common_moderation_and_safety) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } + } + + @Test + fun `click on Report a problem invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(canReportBug = true), + onOpenRageShake = callback, + ) + val text = activity!!.getString(CommonStrings.common_report_a_problem) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } + } + + @Test + fun `when canReportBug is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(canReportBug = false)) + onNodeWithText(activity!!.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist() + } + + @Test + fun `click on Remove this device invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(), + onSignOutClick = callback, + ) + val text = activity!!.getString(CommonStrings.action_signout) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } + } + + @Test + fun `click on Deactivate invokes the expected callback`() = runAndroidComposeUiTest { + ensureCalledOnce { callback -> + setView( + aPreferencesAccountState(canDeactivateAccount = true), + onDeactivateClick = callback, + ) + val text = activity!!.getString(CommonStrings.action_delete_account) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } + } + + @Test + fun `when canDeactivateAccount is false, item is not shown`() = runAndroidComposeUiTest { + setView(aPreferencesAccountState(canDeactivateAccount = false)) + onNodeWithText(activity!!.getString(CommonStrings.action_delete_account)).assertDoesNotExist() + } +} + +private fun AndroidComposeUiTest.setView( + state: PreferencesAccountState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onAvatarClick: (String) -> Unit = EnsureNeverCalledWithParam(), + onEditProfileClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), + onSecureBackupClick: () -> Unit = EnsureNeverCalled(), + onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(), + onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(), + onOpenRageShake: () -> Unit = EnsureNeverCalled(), + onModerationAndSafetyClick: () -> Unit = EnsureNeverCalled(), + onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), + onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(), + onSignOutClick: () -> Unit = EnsureNeverCalled(), + onDeactivateClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + PreferencesAccountView( + state = state, + onBackClick = onBackClick, + onAvatarClick = onAvatarClick, + onEditProfileClick = onEditProfileClick, + onSecureBackupClick = onSecureBackupClick, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + onOpenRageShake = onOpenRageShake, + onModerationAndSafetyClick = onModerationAndSafetyClick, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenBlockedUsers = onOpenBlockedUsers, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt index 891cf44b96..8a012d29c7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt @@ -48,6 +48,18 @@ class AppDeveloperSettingsPageTest : RobolectricTest() { } } + @Test + fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setAppDeveloperSettingsView( + state = anAppDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + clickOn(CommonStrings.action_view_source) + eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(true)) + } + @Config(qualifiers = "h1500dp") @Test fun `clicking on element call url open the dialogs and submit emits the expected event`() = runAndroidComposeUiTest { diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt index f4dad80e8b..9300236293 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt @@ -47,6 +47,7 @@ class AppDeveloperSettingsPresenterTest { ) presenter.test { awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isFalse() assertThat(state.features).isEmpty() assertThat(state.customElementCallBaseUrlState).isNotNull() assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() @@ -130,6 +131,26 @@ class AppDeveloperSettingsPresenterTest { } } + @Test + fun `present - developer mode on off`() = runTest { + val preferences = InMemoryAppPreferencesStore() + val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferences) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isFalse() + state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(true)) + } + awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isTrue() + state.eventSink(AppDeveloperSettingsEvent.SetDeveloperModeEnabled(false)) + } + awaitItem().also { state -> + assertThat(state.isDeveloperModeEnabled).isFalse() + } + } + } + private fun createAppDeveloperSettingsPresenter( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt new file mode 100644 index 0000000000..f0090c3700 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsPresenterTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.location + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LocationSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createLocationSettingsPresenter().test { + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) + } + } + } + + @Test + fun `present - exposes live location minimum distance from app preferences`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 50, + ) + createLocationSettingsPresenter(appPreferencesStore = appPreferencesStore).test { + skipItems(1) + + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50) + } + } + } + + @Test + fun `present - saving live location minimum distance updates app preferences`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore( + liveLocationMinimumDistanceUpdate = 10, + ) + createLocationSettingsPresenter(appPreferencesStore = appPreferencesStore).test { + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) + eventSink(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) + } + with(awaitItem()) { + assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42) + } + } + assertThat(appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(42) + } + + private fun CoroutineScope.createLocationSettingsPresenter( + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + ) = LocationSettingsPresenter( + appPreferencesStore = appPreferencesStore, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt new file mode 100644 index 0000000000..afbef3f97b --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/location/LocationSettingsViewTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.location + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.preferences.impl.R +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class LocationSettingsViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setLocationSettingsView( + state = aLocationSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + pressBack() + } + } + + @Test + fun `the current minimum distance is displayed`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setLocationSettingsView( + state = aLocationSettingsState( + liveLocationMinimumDistanceUpdate = 50, + eventSink = eventsRecorder, + ), + ) + val text = activity!!.resources.getQuantityString( + R.plurals.screen_advanced_settings_live_location_update_distance, + 50, + 50, + ) + onNodeWithText(text).assertExists() + } + + @Test + fun `changing the minimum distance emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setLocationSettingsView( + state = aLocationSettingsState( + liveLocationMinimumDistanceUpdate = 50, + eventSink = eventsRecorder, + ), + ) + // `setProgress` invokes `onValueChange` and then `onValueChangeFinish`, which is what the view listens to. + onNode(SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress)) + .performSemanticsAction(SemanticsActions.SetProgress) { it(42f) } + eventsRecorder.assertSingle(LocationSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) + } +} + +private fun AndroidComposeUiTest.setLocationSettingsView( + state: LocationSettingsState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOpenAppSettingsClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + LocationSettingsView( + state = state, + onBackClick = onBackClick, + onOpenAppSettingsClick = onOpenAppSettingsClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt deleted file mode 100644 index 46ed8c7d0a..0000000000 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsPresenterTest.kt +++ /dev/null @@ -1,393 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.preferences.impl.media - -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.compound.theme.Theme -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore -import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore -import io.element.android.tests.testutils.WarmUpRule -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class AdvancedSettingsPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isFalse() - assertThat(isSharePresenceEnabled).isTrue() - assertThat(mediaOptimizationState).isNull() - assertThat(theme).isEqualTo(ThemeOption.System) - assertThat(availableThemeOptions).isEqualTo( - listOf(ThemeOption.System, ThemeOption.Light, ThemeOption.Dark).toImmutableList() - ) - assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) - } - - // After the initial state, we expect the media optimization state to be set - with(awaitItem()) { - assertThat(mediaOptimizationState).isInstanceOf(MediaOptimizationState.AllMedia::class.java) - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - } - } - } - - @Test - fun `present - developer mode on off`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isFalse() - eventSink(MediaSettingsEvent.SetDeveloperModeEnabled(true)) - } - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isTrue() - eventSink(MediaSettingsEvent.SetDeveloperModeEnabled(false)) - } - with(awaitItem()) { - assertThat(isDeveloperModeEnabled).isFalse() - } - } - } - - @Test - fun `present - share presence off on`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(isSharePresenceEnabled).isTrue() - eventSink(MediaSettingsEvent.SetSharePresenceEnabled(false)) - } - with(awaitItem()) { - assertThat(isSharePresenceEnabled).isFalse() - eventSink(MediaSettingsEvent.SetSharePresenceEnabled(true)) - } - with(awaitItem()) { - assertThat(isSharePresenceEnabled).isTrue() - } - } - } - - @Test - fun `present - compress media off on`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - eventSink(MediaSettingsEvent.SetCompressMedia(false)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() - eventSink(MediaSettingsEvent.SetCompressMedia(true)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() - } - } - } - - @Test - fun `present - compress images off on`() = runTest { - val presenter = createAdvancedSettingsPresenter( - featureFlagService = FakeFeatureFlagService().apply { - setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() - eventSink(MediaSettingsEvent.SetCompressImages(false)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() - eventSink(MediaSettingsEvent.SetCompressImages(true)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() - } - } - } - - @Test - fun `present - video upload quality selector`() = runTest { - val presenter = createAdvancedSettingsPresenter( - featureFlagService = FakeFeatureFlagService().apply { - setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) - eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) - eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) - } - with(awaitItem()) { - assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) - } - } - } - - @Test - fun `present - change theme`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.System) - eventSink(MediaSettingsEvent.SetTheme(ThemeOption.Dark)) - } - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.Dark) - eventSink(MediaSettingsEvent.SetTheme(ThemeOption.Light)) - } - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.Light) - eventSink(MediaSettingsEvent.SetTheme(ThemeOption.System)) - } - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.System) - } - } - } - - @Test - fun `present - exposes live location minimum distance from app preferences`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore( - liveLocationMinimumDistanceUpdate = 50, - ) - val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50) - } - } - } - - @Test - fun `present - saving live location minimum distance updates app preferences`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore( - liveLocationMinimumDistanceUpdate = 10, - ) - val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore) - - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10) - eventSink(MediaSettingsEvent.SetLiveLocationMinimumDistanceUpdate(42)) - } - with(awaitItem()) { - assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42) - } - } - } - - @Test - fun `present - black theme option shown when feature flag enabled`() = runTest { - val presenter = createAdvancedSettingsPresenter( - featureFlagService = FakeFeatureFlagService().apply { - setFeatureEnabled(FeatureFlags.AllowBlackTheme, true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(availableThemeOptions).contains(ThemeOption.Black) - assertThat(availableThemeOptions).isEqualTo(ThemeOption.entries.toImmutableList()) - } - } - } - - @Test - fun `present - stored black theme falls back to dark when feature flag disabled`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore().apply { - setTheme(Theme.Black.name) - } - val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - - with(awaitItem()) { - assertThat(theme).isEqualTo(ThemeOption.Dark) - } - } - } - - @Test - fun `present - hide invite avatars`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore() - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - eventSink(MediaSettingsEvent.SetHideInviteAvatars(true)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() - eventSink(MediaSettingsEvent.SetHideInviteAvatars(false)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() - } - } - assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) - } - - @Test - fun `present - timeline media preview value`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore() - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - eventSink(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) - eventSink(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - } - with(awaitItem()) { - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) - } - } - assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( - listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) - ) - } - - @Test - fun `present - media preview state with custom initial values`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore( - hideInviteAvatarsValue = true, - timelineMediaPreviewValue = MediaPreviewValue.Private - ) - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() - assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) - } - } - } - - @Test - fun `present - async actions state`() = runTest { - val mediaPreviewStore = FakeMediaPreviewConfigStateStore( - setHideInviteAvatarsActionValue = AsyncAction.Loading, - setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) - ) - val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - // Skip until the initial data it loaded - skipItems(1) - - with(awaitItem()) { - assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) - assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) - } - } - } - - private fun CoroutineScope.createAdvancedSettingsPresenter( - appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), - sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), - mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), - featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), - ) = MediaSettingsPresenter( - appPreferencesStore = appPreferencesStore, - sessionPreferencesStore = sessionPreferencesStore, - mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, - featureFlagService = featureFlagService, - sessionCoroutineScope = this, - ) -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt deleted file mode 100644 index 4374ad0f04..0000000000 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/AdvancedSettingsViewTest.kt +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -@file:OptIn(ExperimentalTestApi::class) - -package io.element.android.features.preferences.impl.media - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.AndroidComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.v2.runAndroidComposeUiTest -import com.google.common.truth.Truth.assertThat -import im.vector.app.features.analytics.plan.Interaction -import io.element.android.features.preferences.impl.R -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.ui.strings.CommonStrings -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.compose.LocalAnalyticsService -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.tests.testutils.EnsureNeverCalled -import io.element.android.tests.testutils.EventsRecorder -import io.element.android.tests.testutils.assertNoNodeWithText -import io.element.android.tests.testutils.clickOn -import io.element.android.tests.testutils.ensureCalledOnce -import io.element.android.tests.testutils.pressBack -import io.element.android.tests.testutils.robolectric.RobolectricTest -import kotlinx.collections.immutable.toImmutableList -import org.junit.Test -import org.robolectric.annotation.Config - -class AdvancedSettingsViewTest : RobolectricTest() { - @Test - fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder - ), - onBackClick = it - ) - pressBack() - } - } - - @Test - fun `clicking on other theme emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - ), - ) - clickOn(CommonStrings.common_appearance) - clickOn(R.string.theme_dark) - eventsRecorder.assertSingle(MediaSettingsEvent.SetTheme(ThemeOption.Dark)) - } - - @Test - fun `black theme is shown when available`() = runAndroidComposeUiTest { - setAdvancedSettingsView( - state = aMediaSettingsState( - availableThemeOptions = ThemeOption.entries.toImmutableList(), - ), - ) - clickOn(CommonStrings.common_appearance) - run { - val text = activity!!.getString(R.string.theme_black) - onNodeWithText(text).assertExists() - } - } - - @Test - fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest { - setAdvancedSettingsView( - state = aMediaSettingsState( - availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(), - ), - ) - clickOn(CommonStrings.common_appearance) - assertNoNodeWithText(R.string.theme_black) - } - - @Test - fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - ), - ) - clickOn(CommonStrings.action_view_source) - eventsRecorder.assertSingle(MediaSettingsEvent.SetDeveloperModeEnabled(true)) - } - - @Test - fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - ), - ) - clickOn(R.string.screen_advanced_settings_share_presence) - eventsRecorder.assertSingle(MediaSettingsEvent.SetSharePresenceEnabled(true)) - } - - @Test - fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - val analyticsService = FakeAnalyticsService() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - ), - analyticsService = analyticsService - ) - clickOn(R.string.screen_advanced_settings_media_compression_description) - eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true)) - assertThat(analyticsService.capturedEvents).isEqualTo( - listOf( - Interaction( - name = Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled - ) - ) - ) - } - - @Test - fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - val analyticsService = FakeAnalyticsService() - setAdvancedSettingsView( - state = aMediaSettingsState( - mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), - eventSink = eventsRecorder, - ), - analyticsService = analyticsService - ) - clickOn(R.string.screen_advanced_settings_media_compression_description) - eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(false)) - assertThat(analyticsService.capturedEvents).isEqualTo( - listOf( - Interaction( - name = Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled - ) - ) - ) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - hideInviteAvatars = false - ), - ) - clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) - eventsRecorder.assertSingle(MediaSettingsEvent.SetHideInviteAvatars(true)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.On - ), - ) - clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) - eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.On - ), - ) - clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) - eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.Off - ), - ) - clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) - eventsRecorder.assertSingle(MediaSettingsEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - hideInviteAvatars = false, - setHideInviteAvatarsAction = AsyncAction.Loading - ), - ) - // The toggle should be disabled, so clicking should not emit any events - clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) - } - - @Test - @Config(qualifiers = "h1080dp") - fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setAdvancedSettingsView( - state = aMediaSettingsState( - eventSink = eventsRecorder, - timelineMediaPreviewValue = MediaPreviewValue.On, - setTimelineMediaPreviewAction = AsyncAction.Loading - ), - ) - // The options should be disabled, so clicking should not emit any events - clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) - clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) - } -} - -private fun AndroidComposeUiTest.setAdvancedSettingsView( - state: MediaSettingsState, - analyticsService: AnalyticsService = FakeAnalyticsService(), - onBackClick: () -> Unit = EnsureNeverCalled(), - onOpenAppSettings: () -> Unit = EnsureNeverCalled(), -) { - setContent { - CompositionLocalProvider( - LocalAnalyticsService provides analyticsService, - ) { - MediaSettingsView( - state = state, - onBackClick = onBackClick, - onOpenAppSettingsClick = onOpenAppSettings - ) - } - } -} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt new file mode 100644 index 0000000000..a138c42e04 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsPresenterTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.media + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MediaSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createMediaSettingsPresenter().test { + assertThat(awaitItem().mediaOptimizationState).isNull() + // After the initial state, we expect the media optimization state to be set + with(awaitItem()) { + assertThat(mediaOptimizationState).isEqualTo(MediaOptimizationState.AllMedia(isEnabled = true)) + } + } + } + + @Test + fun `present - compress media off on`() = runTest { + createMediaSettingsPresenter().test { + // Skip until the initial data is loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + eventSink(MediaSettingsEvent.SetCompressMedia(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() + eventSink(MediaSettingsEvent.SetCompressMedia(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + } + } + } + + @Test + fun `present - split media optimization state when the feature flag is enabled`() = runTest { + createMediaSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ).test { + // Skip until the initial data is loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + eventSink(MediaSettingsEvent.SetCompressMedia(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() + eventSink(MediaSettingsEvent.SetCompressMedia(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + } + } + } + + @Test + fun `present - video upload quality selector`() = runTest { + createMediaSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ).test { + // Skip until the initial data is loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) + eventSink(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) + } + } + } + + private fun CoroutineScope.createMediaSettingsPresenter( + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + ) = MediaSettingsPresenter( + sessionPreferencesStore = sessionPreferencesStore, + featureFlagService = featureFlagService, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt new file mode 100644 index 0000000000..1ef879d1ca --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaSettingsViewTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.media + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.assertNoNodeWithText +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class MediaSettingsViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setMediaSettingsView( + state = aMediaSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + pressBack() + } + } + + @Test + fun `when the media optimization state is not loaded, nothing is rendered`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = null, + eventSink = eventsRecorder, + ), + ) + assertNoNodeWithText(R.string.screen_advanced_settings_media_compression_title) + assertNoNodeWithText(R.string.screen_advanced_settings_optimise_image_upload_quality_title) + } + + @Test + fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val analyticsService = FakeAnalyticsService() + setMediaSettingsView( + state = aMediaSettingsState( + eventSink = eventsRecorder, + ), + analyticsService = analyticsService + ) + clickOn(R.string.screen_advanced_settings_media_compression_description) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true)) + assertThat(analyticsService.capturedEvents).isEqualTo( + listOf( + Interaction( + name = Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + ) + ) + ) + } + + @Test + fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val analyticsService = FakeAnalyticsService() + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), + eventSink = eventsRecorder, + ), + analyticsService = analyticsService + ) + clickOn(R.string.screen_advanced_settings_media_compression_description) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(false)) + assertThat(analyticsService.capturedEvents).isEqualTo( + listOf( + Interaction( + name = Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + ) + ) + ) + } + + @Test + fun `clicking on image upload quality in split mode emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = false, + videoPreset = VideoCompressionPreset.STANDARD, + ), + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_optimise_image_upload_quality_title) + eventsRecorder.assertSingle(MediaSettingsEvent.SetCompressMedia(true)) + } + + @Test + fun `selecting a video upload quality emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.STANDARD, + ), + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_title) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_low, inDialog = true) + clickOn(CommonStrings.action_ok, inDialog = true) + eventsRecorder.assertSingle(MediaSettingsEvent.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + } + + @Test + fun `cancelling the video upload quality dialog emits no event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setMediaSettingsView( + state = aMediaSettingsState( + mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.STANDARD, + ), + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_title) + clickOn(R.string.screen_advanced_settings_optimise_video_upload_quality_low, inDialog = true) + clickOn(CommonStrings.action_cancel, inDialog = true) + } +} + +private fun AndroidComposeUiTest.setMediaSettingsView( + state: MediaSettingsState, + analyticsService: AnalyticsService = FakeAnalyticsService(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + CompositionLocalProvider( + LocalAnalyticsService provides analyticsService, + ) { + MediaSettingsView( + state = state, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/FakeMediaPreviewConfigStateStore.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/FakeMediaPreviewConfigStateStore.kt similarity index 97% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/FakeMediaPreviewConfigStateStore.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/FakeMediaPreviewConfigStateStore.kt index 87b48e7d25..b45aaab3d2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/FakeMediaPreviewConfigStateStore.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/FakeMediaPreviewConfigStateStore.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.media +package io.element.android.features.preferences.impl.moderation import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaPreviewConfigStateStoreTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStoreTest.kt similarity index 99% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaPreviewConfigStateStoreTest.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStoreTest.kt index e00c9c586a..b15e7c356b 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/media/MediaPreviewConfigStateStoreTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/MediaPreviewConfigStateStoreTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.preferences.impl.media +package io.element.android.features.preferences.impl.moderation import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt new file mode 100644 index 0000000000..dec3803338 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyPresenterTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.moderation + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ModerationAndSafetyPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createModerationAndSafetyPresenter().test { + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - share presence off on`() = runTest { + createModerationAndSafetyPresenter().test { + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(false)) + } + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isFalse() + eventSink(ModerationAndSafetyEvent.SetSharePresenceEnabled(true)) + } + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + } + } + } + + @Test + fun `present - hide invite avatars`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(true)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + eventSink(ModerationAndSafetyEvent.SetHideInviteAvatars(false)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + } + } + assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) + } + + @Test + fun `present - timeline media preview value`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + eventSink(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( + listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) + ) + } + + @Test + fun `present - media preview state with custom initial values`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + hideInviteAvatarsValue = true, + timelineMediaPreviewValue = MediaPreviewValue.Private + ) + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + } + + @Test + fun `present - async actions state`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + setHideInviteAvatarsActionValue = AsyncAction.Loading, + setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) + ) + createModerationAndSafetyPresenter(mediaPreviewConfigStateStore = mediaPreviewStore).test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + } + + private fun CoroutineScope.createModerationAndSafetyPresenter( + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), + ) = ModerationAndSafetyPresenter( + sessionPreferencesStore = sessionPreferencesStore, + mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt new file mode 100644 index 0000000000..3589f24154 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/moderation/ModerationAndSafetyViewTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.features.preferences.impl.moderation + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +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 io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.annotation.Config + +class ModerationAndSafetyViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + pressBack() + } + } + + @Test + fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + ), + ) + clickOn(R.string.screen_advanced_settings_share_presence) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetSharePresenceEnabled(true)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + hideInviteAvatars = false + ), + ) + clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetHideInviteAvatars(true)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On + ), + ) + clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On + ), + ) + clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.Off + ), + ) + clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) + eventsRecorder.assertSingle(ModerationAndSafetyEvent.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + hideInviteAvatars = false, + setHideInviteAvatarsAction = AsyncAction.Loading + ), + ) + // The toggle should be disabled, so clicking should not emit any events + clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setModerationAndSafetyView( + state = aModerationAndSafetyState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On, + setTimelineMediaPreviewAction = AsyncAction.Loading + ), + ) + // The options should be disabled, so clicking should not emit any events + clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) + clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) + } +} + +private fun AndroidComposeUiTest.setModerationAndSafetyView( + state: ModerationAndSafetyState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + ModerationAndSafetyView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 051c8bd12d..7d8ad72cf9 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -12,31 +12,22 @@ package io.element.android.features.preferences.impl.root import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.compound.theme.Theme import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider -import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.indicator.api.IndicatorService -import io.element.android.libraries.indicator.test.FakeIndicatorService -import io.element.android.libraries.matrix.api.oauth.AccountManagementAction import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 -import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta -import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore @@ -48,11 +39,10 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -63,19 +53,8 @@ class PreferencesRootPresenterTest { @Test fun `present - initial state`() = runTest { - val accountManagementUrlResult = lambdaRecorder> { action -> - Result.success("$action url") - } - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = accountManagementUrlResult, - ) - createPresenter( - matrixClient = matrixClient, - sessionEnterpriseService = FakeSessionEnterpriseService( - tweakMasUrlResult = { "tweaked $it" }, - ), - ).test { + val matrixClient = FakeMatrixClient() + createPreferencesRootPresenter(matrixClient = matrixClient).test { val initialState = awaitItem() assertThat(initialState.myUser).isEqualTo( MatrixUser( @@ -88,106 +67,82 @@ class PreferencesRootPresenterTest { assertThat(initialState.isMultiAccountEnabled).isFalse() assertThat(initialState.isOtherAccountsSectionExpanded).isTrue() assertThat(initialState.otherSessions).isEmpty() - assertThat(initialState.version).isEqualTo("A Version") val loadedState = awaitItem() - assertThat(loadedState.myUser).isEqualTo( - MatrixUser( - userId = matrixClient.sessionId, - displayName = A_USER_NAME, - avatarUrl = AN_AVATAR_URL - ) + assertThat(loadedState.theme).isEqualTo(ThemeOption.System) + assertThat(loadedState.availableThemeOptions).isEqualTo( + listOf(ThemeOption.System, ThemeOption.Light, ThemeOption.Dark).toImmutableList() ) - assertThat(loadedState.showSecureBackup).isFalse() - assertThat(loadedState.showSecureBackupBadge).isFalse() - assertThat(loadedState.accountManagementUrl).isNull() + assertThat(loadedState.userStatusState).isNotNull() assertThat(loadedState.showAnalyticsSettings).isFalse() - assertThat(loadedState.showLinkNewDevice).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() - assertThat(loadedState.canDeactivateAccount).isTrue() - assertThat(loadedState.canReportBug).isTrue() - assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0) - assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState()) + assertThat(loadedState.showLabsItem).isFalse() assertThat(loadedState.snackbarMessage).isNull() - val finalState = awaitItem() - accountManagementUrlResult.assertions().isCalledOnce() - .with(value(null)) - assertThat(finalState.accountManagementUrl).isEqualTo("tweaked null url") } } @Test - fun `present - cannot report bug`() = runTest { - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success("") }, - ) - createPresenter( - matrixClient = matrixClient, - rageshakeFeatureAvailability = { flowOf(false) }, + fun `present - user status state is null when the server does not support it`() = runTest { + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient().apply { + isUserStatusSupportedResult = Result.success(false) + }, ).test { - val initialState = awaitItem() - assertThat(initialState.canReportBug).isFalse() + consumeItemsUntilPredicate { it.userStatusState == null } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change theme`() = runTest { + createPreferencesRootPresenter().test { + // Skip until the initial data is loaded skipItems(1) + + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.System) + eventSink(PreferencesRootEvent.SetTheme(ThemeOption.Dark)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.Dark) + eventSink(PreferencesRootEvent.SetTheme(ThemeOption.Light)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.Light) + eventSink(PreferencesRootEvent.SetTheme(ThemeOption.System)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.System) + } } } @Test - fun `present - number of blocked users`() = runTest { - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success("") }, - ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)), - ) - createPresenter( - matrixClient = matrixClient, + fun `present - black theme option shown when feature flag enabled`() = runTest { + createPreferencesRootPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.AllowBlackTheme, true) + } ).test { - skipItems(1) - val initialState = awaitItem() - assertThat(initialState.nbOfBlockedUsers).isEqualTo(2) + val state = consumeItemsUntilPredicate { it.availableThemeOptions.contains(ThemeOption.Black) }.last() + assertThat(state.availableThemeOptions).isEqualTo(ThemeOption.entries.toImmutableList()) + cancelAndIgnoreRemainingEvents() } } @Test - fun `present - secure backup badge`() = runTest { - val matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success("") }, - ) - val indicatorService = FakeIndicatorService() - createPresenter( - matrixClient = matrixClient, - rageshakeFeatureAvailability = { flowOf(false) }, - indicatorService = indicatorService, - ).test { - skipItems(1) - val initialState = awaitItem() - assertThat(initialState.showSecureBackupBadge).isFalse() - indicatorService.setShowSettingChatBackupIndicator(true) - val finalState = awaitItem() - assertThat(finalState.showSecureBackupBadge).isTrue() + fun `present - stored black theme falls back to dark when feature flag disabled`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setTheme(Theme.Black.name) } - } - - @Test - fun `present - can deactivate account is false if the Matrix client say so`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { false }, - accountManagementUrlResult = { Result.success(null) }, - ), - ).test { - val loadedState = awaitFirstItem() - assertThat(loadedState.canDeactivateAccount).isFalse() + createPreferencesRootPresenter(appPreferencesStore = appPreferencesStore).test { + consumeItemsUntilPredicate { it.theme == ThemeOption.Dark } + cancelAndIgnoreRemainingEvents() } } @Test fun `present - developer settings is hidden by default in release builds`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), + createPreferencesRootPresenter( showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) ).test { val loadedState = awaitFirstItem() @@ -197,11 +152,7 @@ class PreferencesRootPresenterTest { @Test fun `present - developer settings can be enabled in release builds`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), + createPreferencesRootPresenter( showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) ).test { val loadedState = awaitFirstItem() @@ -223,13 +174,7 @@ class PreferencesRootPresenterTest { ), setLatestSessionResult = setLatestSessionResult, ) - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), - sessionStore = sessionStore, - ).test { + createPreferencesRootPresenter(sessionStore = sessionStore).test { val loadedState = awaitFirstItem() loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) setLatestSessionResult.assertions().isCalledOnce() @@ -239,7 +184,7 @@ class PreferencesRootPresenterTest { @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { - createPresenter( + createPreferencesRootPresenter( featureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> listOf( @@ -252,10 +197,6 @@ class PreferencesRootPresenterTest { ) } ), - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), ).test { assertThat(awaitItem().showLabsItem).isTrue() cancelAndIgnoreRemainingEvents() @@ -264,16 +205,12 @@ class PreferencesRootPresenterTest { @Test fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest { - createPresenter( + createPreferencesRootPresenter( featureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> emptyList() } ), - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), ).test { skipItems(1) assertThat(awaitItem().showLabsItem).isFalse() @@ -283,11 +220,8 @@ class PreferencesRootPresenterTest { @Test fun `present - multiple accounts`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - sessionId = A_SESSION_ID, - canDeactivateAccountResult = { true }, - ), + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID), featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.MultiAccount.key to true) ), @@ -311,11 +245,7 @@ class PreferencesRootPresenterTest { @Test fun `present - other accounts section is collapsed if the preference says so`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), + createPreferencesRootPresenter( appPreferencesStore = InMemoryAppPreferencesStore(isOtherAccountsExpanded = false), ).test { consumeItemsUntilPredicate { !it.isOtherAccountsSectionExpanded } @@ -326,13 +256,7 @@ class PreferencesRootPresenterTest { @Test fun `present - toggle other accounts expanded stores the new value`() = runTest { val appPreferencesStore = InMemoryAppPreferencesStore() - createPresenter( - matrixClient = FakeMatrixClient( - canDeactivateAccountResult = { true }, - accountManagementUrlResult = { Result.success(null) }, - ), - appPreferencesStore = appPreferencesStore, - ).test { + createPreferencesRootPresenter(appPreferencesStore = appPreferencesStore).test { val initialState = awaitFirstItem() assertThat(initialState.isOtherAccountsSectionExpanded).isTrue() initialState.eventSink(PreferencesRootEvent.ToggleOtherAccountsExpanded) @@ -346,51 +270,27 @@ class PreferencesRootPresenterTest { } } - @Test - fun `present - link new device`() = runTest { - createPresenter( - matrixClient = FakeMatrixClient( - sessionId = A_SESSION_ID, - canDeactivateAccountResult = { true }, - ), - featureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.QrCodeLogin.key to true) - ), - ).test { - val state = awaitFirstItem() - assertThat(state.showLinkNewDevice).isTrue() - } - } - private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() } - private fun createPresenter( + private fun CoroutineScope.createPreferencesRootPresenter( matrixClient: FakeMatrixClient = FakeMatrixClient(), - sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), - rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) }, - indicatorService: IndicatorService = FakeIndicatorService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), sessionStore: SessionStore = InMemorySessionStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), - sessionEnterpriseService: SessionEnterpriseService = FakeSessionEnterpriseService(), ) = PreferencesRootPresenter( matrixClient = matrixClient, - sessionVerificationService = sessionVerificationService, analyticsService = FakeAnalyticsService(), versionFormatter = FakeVersionFormatter(), snackbarDispatcher = SnackbarDispatcher(), - indicatorService = indicatorService, - directLogoutPresenter = { aDirectLogoutState() }, showDeveloperSettingsProvider = showDeveloperSettingsProvider, - rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, sessionStore = sessionStore, appPreferencesStore = appPreferencesStore, - sessionEnterpriseService = sessionEnterpriseService, userStatusPresenter = { aUserStatusState() }, + sessionCoroutineScope = this, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index a7cf928edc..973aedc194 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -20,20 +20,18 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled -import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.assertNoNodeWithText import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce -import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.toImmutableList import org.junit.Test class PreferencesRootViewTest : RobolectricTest() { @@ -52,16 +50,15 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on User profile invokes the expected callback`() = runAndroidComposeUiTest { + fun `click on the user row invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) - val user = aMatrixUser() - ensureCalledOnceWithParam(user) { callback -> + ensureCalledOnce { callback -> setView( aPreferencesRootState( - myUser = user, + myUser = aMatrixUser(), eventSink = eventsRecorder, ), - onOpenUserProfile = callback, + onOpenAccountSettings = callback, ) onNodeWithText("Alice").performClick() } @@ -178,84 +175,85 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on Encryption invokes the expected callback`() = runAndroidComposeUiTest { + fun `clicking on another theme emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_appearance) + clickOn(R.string.theme_dark) + eventsRecorder.assertSingle(PreferencesRootEvent.SetTheme(ThemeOption.Dark)) + } + + @Test + fun `black theme is shown when available`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + availableThemeOptions = ThemeOption.entries.toImmutableList(), + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_appearance) + onNodeWithText(activity!!.getString(R.string.theme_black)).assertExists() + } + + @Test + fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(), + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_appearance) + assertNoNodeWithText(R.string.theme_black) + } + + @Test + fun `click on Media upload quality invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> setView( aPreferencesRootState( - showSecureBackup = true, eventSink = eventsRecorder, ), - onSecureBackupClick = callback, + onOpenMediaSettings = callback, ) - clickOn(CommonStrings.common_encryption) + clickOn(CommonStrings.common_media_upload_quality) } } @Test - fun `when showSecureBackup is false, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - showSecureBackup = false, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.common_encryption)).assertDoesNotExist() - } - - @Test - fun `click on Manage account invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnceWithParam("aUrl") { callback -> - setView( - aPreferencesRootState( - accountManagementUrl = "aUrl", - eventSink = eventsRecorder, - ), - onManageAccountClick = callback, - ) - clickOn(CommonStrings.action_manage_account_and_devices) - } - } - - @Test - fun `when accountManagementUrl is null, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - accountManagementUrl = null, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist() - } - - @Test - fun `click on Link new devices invokes the expected callback`() = runAndroidComposeUiTest { + fun `click on Screen lock invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> setView( aPreferencesRootState( - showLinkNewDevice = true, eventSink = eventsRecorder, ), - onLinkNewDeviceClick = callback, + onOpenLockScreenSettings = callback, ) - clickOn(CommonStrings.common_link_new_device) + clickOn(CommonStrings.common_screen_lock) } } @Test - fun `when showLinkNewDevice is false, item is not shown`() = runAndroidComposeUiTest { + fun `click on Location sharing invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - showLinkNewDevice = false, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() + ensureCalledOnce { callback -> + setView( + aPreferencesRootState( + eventSink = eventsRecorder, + ), + onOpenLocationSettings = callback, + ) + val text = activity!!.getString(CommonStrings.common_location_sharing) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() + } } @Test @@ -287,45 +285,31 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on Report a problem invokes the expected callback`() = runAndroidComposeUiTest { + fun `click on Labs invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> setView( aPreferencesRootState( - canReportBug = true, + showLabsItem = true, eventSink = eventsRecorder, ), - onOpenRageShake = callback, + onOpenLabs = callback, ) - val text = activity!!.getString(CommonStrings.common_report_a_problem) + val text = activity!!.getString(R.string.screen_labs_title) onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @Test - fun `when canReportBug is false, item is not shown`() = runAndroidComposeUiTest { + fun `when showLabsItem is false, item is not shown`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) setView( aPreferencesRootState( - canReportBug = false, + showLabsItem = false, eventSink = eventsRecorder, ), ) - onNodeWithText(activity!!.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist() - } - - @Test - fun `click on Screen lock invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onOpenLockScreenSettings = callback, - ) - clickOn(CommonStrings.common_screen_lock) - } + onNodeWithText(activity!!.getString(R.string.screen_labs_title)).assertDoesNotExist() } @Test @@ -338,7 +322,8 @@ class PreferencesRootViewTest : RobolectricTest() { ), onOpenAbout = callback, ) - clickOn(CommonStrings.common_about) + val text = activity!!.getString(CommonStrings.common_about) + onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() } } @@ -371,128 +356,15 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `click on Advanced settings invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onOpenAdvancedSettings = callback, - ) - clickOn(CommonStrings.common_advanced_settings) - } - } - - @Test - fun `click on Labs invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - showLabsItem = true, - eventSink = eventsRecorder, - ), - onOpenLabs = callback, - ) - clickOn(R.string.screen_labs_title) - } - } - - @Test - fun `when showLabsItem is false, item is not shown`() = runAndroidComposeUiTest { + fun `when userStatusState is null, the status section is not shown`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) setView( aPreferencesRootState( - showLabsItem = false, + userStatusState = null, eventSink = eventsRecorder, ), ) - onNodeWithText(activity!!.getString(R.string.screen_labs_title)).assertDoesNotExist() - } - - @Test - fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onOpenNotificationSettings = callback, - ) - clickOn(R.string.screen_notification_settings_title) - } - } - - @Test - fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - nbOfBlockedUsers = 1, - eventSink = eventsRecorder, - ), - onOpenBlockedUsers = callback, - ) - clickOn(CommonStrings.common_blocked_users) - } - } - - @Test - fun `when nbOfBlockedUsers is 0, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - nbOfBlockedUsers = 0, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist() - } - - @Test - fun `click on Remove this device invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - eventSink = eventsRecorder, - ), - onSignOutClick = callback, - ) - val text = activity!!.getString(CommonStrings.action_signout) - onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() - } - } - - @Test - fun `click on Deactivate invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - setView( - aPreferencesRootState( - canDeactivateAccount = true, - eventSink = eventsRecorder, - ), - onDeactivateClick = callback, - ) - val text = activity!!.getString(CommonStrings.action_delete_account) - onNode(hasText(text) and hasClickAction()).performScrollTo().performClick() - } - } - - @Test - fun `when canDeactivateAccount is false, item is not shown`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - setView( - aPreferencesRootState( - canDeactivateAccount = false, - eventSink = eventsRecorder, - ), - ) - onNodeWithText(activity!!.getString(CommonStrings.action_delete_account)).assertDoesNotExist() + assertNoNodeWithText(R.string.screen_settings_user_status_placeholder) } @Test @@ -514,21 +386,14 @@ private fun AndroidComposeUiTest.setView( state: PreferencesRootState, onBackClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(), - onSecureBackupClick: () -> Unit = EnsureNeverCalled(), - onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(), - onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(), onOpenAnalytics: () -> Unit = EnsureNeverCalled(), - onOpenRageShake: () -> Unit = EnsureNeverCalled(), onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), onOpenAbout: () -> Unit = EnsureNeverCalled(), onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(), - onOpenAdvancedSettings: () -> Unit = EnsureNeverCalled(), + onOpenMediaSettings: () -> Unit = EnsureNeverCalled(), + onOpenLocationSettings: () -> Unit = EnsureNeverCalled(), onOpenLabs: () -> Unit = EnsureNeverCalled(), - onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(), - onOpenUserProfile: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), - onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(), - onSignOutClick: () -> Unit = EnsureNeverCalled(), - onDeactivateClick: () -> Unit = EnsureNeverCalled(), + onOpenAccountSettings: () -> Unit = EnsureNeverCalled(), ) { setContent { PreferencesRootView( @@ -536,21 +401,14 @@ private fun AndroidComposeUiTest.setView( emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = onBackClick, onAddAccountClick = onAddAccountClick, - onSecureBackupClick = onSecureBackupClick, - onManageAccountClick = onManageAccountClick, - onLinkNewDeviceClick = onLinkNewDeviceClick, onOpenAnalytics = onOpenAnalytics, - onOpenRageShake = onOpenRageShake, onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, onOpenDeveloperSettings = onOpenDeveloperSettings, - onOpenMediaSettings = onOpenAdvancedSettings, + onOpenMediaSettings = onOpenMediaSettings, + onOpenLocationSettings = onOpenLocationSettings, onOpenLabs = onOpenLabs, - onOpenNotificationSettings = onOpenNotificationSettings, - onOpenUserProfile = onOpenUserProfile, - onOpenBlockedUsers = onOpenBlockedUsers, - onSignOutClick = onSignOutClick, - onDeactivateClick = onDeactivateClick, + onOpenAccountSettings = onOpenAccountSettings, ) } }