Merge pull request #2433 from element-hq/feature/bma/testLogoutDialog

Test direct logout dialog and RoomDetailsView
This commit is contained in:
Benoit Marty
2024-02-22 17:09:16 +01:00
committed by GitHub
24 changed files with 674 additions and 116 deletions

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.logout.api.direct
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class DirectLogoutStateProvider : PreviewParameterProvider<DirectLogoutState> {
override val values: Sequence<DirectLogoutState>
get() = sequenceOf(
aDirectLogoutState(),
aDirectLogoutState(logoutAction = AsyncAction.Confirming),
aDirectLogoutState(logoutAction = AsyncAction.Loading),
aDirectLogoutState(logoutAction = AsyncAction.Failure(Exception("Error"))),
aDirectLogoutState(logoutAction = AsyncAction.Success("success")),
)
}
fun aDirectLogoutState(
canDoDirectSignOut: Boolean = true,
logoutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
eventSink: (DirectLogoutEvents) -> Unit = {},
) = DirectLogoutState(
canDoDirectSignOut = canDoDirectSignOut,
logoutAction = logoutAction,
eventSink = eventSink,
)

View File

@@ -17,11 +17,15 @@
package io.element.android.features.logout.impl.direct
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.DirectLogoutStateProvider
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.logout.impl.ui.LogoutActionDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.di.SessionScope
import javax.inject.Inject
@@ -50,3 +54,14 @@ class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView {
)
}
}
@PreviewsDayNight
@Composable
internal fun DefaultDirectLogoutViewPreview(
@PreviewParameter(DirectLogoutStateProvider::class) state: DirectLogoutState,
) = ElementPreview {
DefaultDirectLogoutView().Render(
state = state,
onSuccessLogout = {},
)
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
@@ -32,6 +33,7 @@ import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@@ -41,16 +43,11 @@ class LogoutViewTest {
@Test
fun `clicking on logout sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent {
LogoutView(
aLogoutState(
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.setLogoutView(
aLogoutState(
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(LogoutEvents.Logout(false))
}
@@ -58,17 +55,12 @@ class LogoutViewTest {
@Test
fun `confirming logout sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent {
LogoutView(
aLogoutState(
logoutAction = AsyncAction.Confirming,
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.setLogoutView(
aLogoutState(
logoutAction = AsyncAction.Confirming,
eventSink = eventsRecorder
),
)
rule.pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(LogoutEvents.Logout(false))
}
@@ -77,16 +69,12 @@ class LogoutViewTest {
fun `clicking on back invoke back callback`() {
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setContent {
LogoutView(
aLogoutState(
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = callback,
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.setLogoutView(
aLogoutState(
eventSink = eventsRecorder
),
onBackClicked = callback,
)
rule.pressBack()
}
}
@@ -94,17 +82,12 @@ class LogoutViewTest {
@Test
fun `clicking on confirm after error sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent {
LogoutView(
aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.setLogoutView(
aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(LogoutEvents.Logout(true))
}
@@ -112,17 +95,12 @@ class LogoutViewTest {
@Test
fun `clicking on cancel after error sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent {
LogoutView(
aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.setLogoutView(
aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(LogoutEvents.CloseDialogs)
}
@@ -132,17 +110,13 @@ class LogoutViewTest {
val data = "data"
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnceWithParam<String?>(data) { callback ->
rule.setContent {
LogoutView(
aLogoutState(
logoutAction = AsyncAction.Success(data),
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = callback,
)
}
rule.setLogoutView(
aLogoutState(
logoutAction = AsyncAction.Success(data),
eventSink = eventsRecorder
),
onSuccessLogout = callback,
)
}
}
@@ -150,18 +124,30 @@ class LogoutViewTest {
fun `last session setting button invoke onChangeRecoveryKeyClicked`() {
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setContent {
LogoutView(
aLogoutState(
isLastDevice = true,
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = callback,
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.setLogoutView(
aLogoutState(
isLastDevice = true,
eventSink = eventsRecorder
),
onChangeRecoveryKeyClicked = callback,
)
rule.clickOn(CommonStrings.common_settings)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLogoutView(
state: LogoutState,
onChangeRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
onBackClicked: () -> Unit = EnsureNeverCalled(),
onSuccessLogout: (logoutUrlResult: String?) -> Unit = EnsureNeverCalledWithParam()
) {
setContent {
LogoutView(
state = state,
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
onBackClicked = onBackClicked,
onSuccessLogout = onSuccessLogout,
)
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.logout.impl.direct
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultDirectLogoutViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on confirm logout sends expected Event`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Confirming,
eventSink = eventsRecorder,
)
)
rule.clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(false))
}
@Test
fun `clicking on cancel logout sends expected Event`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Confirming,
eventSink = eventsRecorder,
)
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
}
@Ignore("Pressing back key should dismiss the dialog, and so generate the expected event, but it's not the case.")
@Test
fun `clicking on back invoke back callback`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Confirming,
eventSink = eventsRecorder,
)
)
rule.pressBackKey()
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
}
@Test
fun `clicking on confirm after error sends expected Event`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
)
)
rule.clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(true))
}
@Test
fun `clicking on cancel after error sends expected Event`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
)
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
}
@Test
fun `success logout invoke expected callback and sends expected Event`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>(expectEvents = false)
ensureCalledOnceWithParam<String?>(null) { callback ->
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Success(null),
eventSink = eventsRecorder,
),
onSuccessLogout = callback
)
}
}
@Test
fun `success logout invoke expected callback and sends expected Event with data`() {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>(expectEvents = false)
val data = "data"
ensureCalledOnceWithParam<String?>(data) { callback ->
rule.setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Success(data),
eventSink = eventsRecorder,
),
onSuccessLogout = callback
)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDefaultDirectLogoutView(
state: DirectLogoutState,
onSuccessLogout: (String?) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
DefaultDirectLogoutView().Render(
state,
onSuccessLogout = onSuccessLogout,
)
}
}

View File

@@ -16,8 +16,7 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.ui.strings.CommonStrings
@@ -37,9 +36,3 @@ fun aPreferencesRootState() = PreferencesRootState(
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),
)
fun aDirectLogoutState() = DirectLogoutState(
canDoDirectSignOut = true,
logoutAction = AsyncAction.Uninitialized,
eventSink = {},
)

View File

@@ -51,6 +51,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)

View File

@@ -17,7 +17,9 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -29,18 +31,18 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
override val values: Sequence<RoomDetailsState>
get() = sequenceOf(
aRoomDetailsState(),
aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null),
aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true),
aRoomDetailsState().copy(isFavorite = true),
aRoomDetailsState().copy(
aRoomDetailsState(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState(isEncrypted = false),
aRoomDetailsState(roomAlias = null),
aDmRoomDetailsState(),
aDmRoomDetailsState(isDmMemberIgnored = true),
aRoomDetailsState(canInvite = true),
aRoomDetailsState(isFavorite = true),
aRoomDetailsState(
canEdit = true,
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
),
// Add other state here
)
@@ -68,32 +70,61 @@ fun aDmRoomMember(
role = role,
)
fun aRoomDetailsState() = RoomDetailsState(
roomId = "a room id",
roomName = "Marketing",
roomAlias = "#marketing:domain.com",
roomAvatarUrl = null,
roomTopic = RoomTopicState.ExistingTopic(
fun aRoomDetailsState(
roomId: String = "a room id",
roomName: String = "Marketing",
roomAlias: String? = "#marketing:domain.com",
roomAvatarUrl: String? = null,
roomTopic: RoomTopicState = RoomTopicState.ExistingTopic(
"Welcome to #marketing, home of the Marketing team " +
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
"|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing..."
),
memberCount = 32,
isEncrypted = true,
canInvite = false,
canEdit = false,
canShowNotificationSettings = true,
roomType = RoomDetailsType.Room,
roomMemberDetailsState = null,
leaveRoomState = aLeaveRoomState(),
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false),
isFavorite = false,
eventSink = {}
memberCount: Long = 32,
isEncrypted: Boolean = true,
canInvite: Boolean = false,
canEdit: Boolean = false,
canShowNotificationSettings: Boolean = true,
roomType: RoomDetailsType = RoomDetailsType.Room,
roomMemberDetailsState: RoomMemberDetailsState? = null,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
isFavorite: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
roomName = roomName,
roomAlias = roomAlias,
roomAvatarUrl = roomAvatarUrl,
roomTopic = roomTopic,
memberCount = memberCount,
isEncrypted = isEncrypted,
canInvite = canInvite,
canEdit = canEdit,
canShowNotificationSettings = canShowNotificationSettings,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettings,
isFavorite = isFavorite,
eventSink = eventSink
)
fun aDmRoomDetailsState(isDmMemberIgnored: Boolean = false) = aRoomDetailsState().copy(
fun aRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.MUTE,
isDefault: Boolean = false,
) = RoomNotificationSettings(
mode = mode,
isDefault = isDefault
)
fun aDmRoomDetailsState(
isDmMemberIgnored: Boolean = false,
roomName: String = "Daniel",
) = aRoomDetailsState(
roomName = roomName,
roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)),
roomMemberDetailsState = aRoomMemberDetailsState()
)

View File

@@ -53,6 +53,7 @@ import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -80,6 +81,8 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -221,7 +224,7 @@ private fun RoomDetailsTopBar(
actions = {
if (showEdit) {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, "")
Icon(Icons.Default.MoreVert, stringResource(id = CommonStrings.a11y_user_menu))
}
DropdownMenu(
expanded = showMenu,
@@ -299,6 +302,7 @@ private fun RoomHeaderSection(
modifier = Modifier
.size(70.dp)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
@@ -463,6 +467,7 @@ internal fun RoomDetailsPreview(@PreviewParameter(RoomDetailsStateProvider::clas
internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: RoomDetailsState) {
RoomDetailsView(

View File

@@ -37,6 +37,8 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable
fun RoomMemberHeaderSection(
@@ -53,6 +55,7 @@ fun RoomMemberHeaderSection(
modifier = Modifier
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.fillMaxSize()
.testTag(TestTags.memberDetailAvatar)
)
}
Spacer(modifier = Modifier.height(24.dp))

View File

@@ -17,10 +17,10 @@
package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.aRoomNotificationSettings
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState>
@@ -43,7 +43,7 @@ internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<
return RoomNotificationSettingsState(
showUserDefinedSettingStyle = false,
roomName = "Room 1",
AsyncData.Success(RoomNotificationSettings(
AsyncData.Success(aRoomNotificationSettings(
mode = RoomNotificationMode.MUTE,
isDefault = isDefault
)),

View File

@@ -17,10 +17,10 @@
package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.aRoomNotificationSettings
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState>
@@ -29,7 +29,7 @@ internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParamet
showUserDefinedSettingStyle = false,
roomName = "Room 1",
AsyncData.Success(
RoomNotificationSettings(
aRoomNotificationSettings(
mode = RoomNotificationMode.MUTE,
isDefault = false
)

View File

@@ -0,0 +1,275 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
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.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RoomDetailsViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `click on back invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
goBack = callback,
)
rule.pressBack()
}
}
@Test
fun `click on share invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
onShareRoom = callback,
)
rule.clickOn(R.string.screen_room_details_share_room_title)
}
}
@Test
fun `click on share member invokes expected callback`() {
val state = aDmRoomDetailsState()
val roomMember = (state.roomType as RoomDetailsType.Dm).roomMember
ensureCalledOnceWithParam(roomMember) { callback ->
rule.setRoomDetailView(
state = aDmRoomDetailsState(),
onShareMember = callback,
)
rule.clickOn(CommonStrings.action_share)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on room members invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
openRoomMemberList = callback,
)
rule.clickOn(CommonStrings.common_people)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on polls invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
openPollHistory = callback,
)
rule.clickOn(R.string.screen_polls_history_title)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on notification invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
openRoomNotificationSettings = callback,
)
rule.clickOn(R.string.screen_room_details_notification_title)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on invite people invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
canInvite = true,
),
invitePeople = callback,
)
rule.clickOn(R.string.screen_room_details_invite_people_title)
}
}
@Test
fun `click on add topic emit expected event`() {
ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.AddTopic) { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
roomTopic = RoomTopicState.CanAddTopic,
),
onActionClicked = callback,
)
rule.clickOn(R.string.screen_room_details_add_topic_title)
}
}
@Test
fun `click on menu edit emit expected event`() {
ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.Edit) { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
canEdit = true,
),
onActionClicked = callback,
)
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
rule.onNodeWithContentDescription(menuContentDescription).performClick()
rule.clickOn(CommonStrings.action_edit)
}
}
@Test
fun `click on avatar test`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false)
val state = aRoomDetailsState(
eventSink = eventsRecorder,
roomAvatarUrl = "an_avatar_url",
)
val callback = EnsureCalledOnceWithTwoParams(state.roomName, "an_avatar_url")
rule.setRoomDetailView(
state = state,
openAvatarPreview = callback,
)
rule.onNodeWithTag(TestTags.roomDetailAvatar.value).performClick()
callback.assertSuccess()
}
@Test
fun `click on avatar test on DM`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false)
val state = aRoomDetailsState(
roomType = RoomDetailsType.Dm(aDmRoomMember(avatarUrl = "an_avatar_url")),
eventSink = eventsRecorder,
)
val callback = EnsureCalledOnceWithTwoParams("Daniel", "an_avatar_url")
rule.setRoomDetailView(
state = state,
openAvatarPreview = callback,
)
rule.onNodeWithTag(TestTags.memberDetailAvatar.value).performClick()
callback.assertSuccess()
}
@Test
fun `click on mute emit expected event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
val state = aRoomDetailsState(
eventSink = eventsRecorder,
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES),
)
rule.setRoomDetailView(
state = state,
)
rule.clickOn(CommonStrings.common_mute)
eventsRecorder.assertSingle(RoomDetailsEvent.MuteNotification)
}
@Test
fun `click on unmute emit expected event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
val state = aRoomDetailsState(
eventSink = eventsRecorder,
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.MUTE),
)
rule.setRoomDetailView(
state = state,
)
rule.clickOn(CommonStrings.common_unmute)
eventsRecorder.assertSingle(RoomDetailsEvent.UnmuteNotification)
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on favorite emit expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.common_favourite)
eventsRecorder.assertSingle(RoomDetailsEvent.SetFavorite(true))
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on leave emit expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>()
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_room_details_leave_room_title)
eventsRecorder.assertSingle(RoomDetailsEvent.LeaveRoom)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailView(
state: RoomDetailsState = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
),
goBack: () -> Unit = EnsureNeverCalled(),
onActionClicked: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(),
onShareRoom: () -> Unit = EnsureNeverCalled(),
onShareMember: (RoomMember) -> Unit = EnsureNeverCalledWithParam(),
openRoomMemberList: () -> Unit = EnsureNeverCalled(),
openRoomNotificationSettings: () -> Unit = EnsureNeverCalled(),
invitePeople: () -> Unit = EnsureNeverCalled(),
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
openPollHistory: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsView(
state = state,
goBack = goBack,
onActionClicked = onActionClicked,
onShareRoom = onShareRoom,
onShareMember = onShareMember,
openRoomMemberList = openRoomMemberList,
openRoomNotificationSettings = openRoomNotificationSettings,
invitePeople = invitePeople,
openAvatarPreview = openAvatarPreview,
openPollHistory = openPollHistory,
)
}
}

View File

@@ -48,6 +48,16 @@ object TestTags {
*/
val homeScreenSettings = TestTag("home_screen-settings")
/**
* Room detail screen.
*/
val roomDetailAvatar = TestTag("room_detail-avatar")
/**
* Room member screen.
*/
val memberDetailAvatar = TestTag("member_detail-avatar")
/**
* Welcome screen.
*/

View File

@@ -55,6 +55,25 @@ class EnsureCalledOnceWithParam<T, R>(
}
}
class EnsureCalledOnceWithTwoParams<T, U>(
private val expectedParam1: T,
private val expectedParam2: U,
) : (T, U) -> Unit {
private var counter = 0
override fun invoke(p1: T, p2: U) {
if (p1 != expectedParam1 || p2 != expectedParam2) {
throw AssertionError("Expected to be called with $expectedParam1 and $expectedParam2, but was called with $p1 and $p2")
}
counter++
}
fun assertSuccess() {
if (counter != 1) {
throw AssertionError("Expected to be called once, but was called $counter times")
}
}
}
/**
* Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
*/