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
commit ee443de771
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 package io.element.android.features.logout.impl.direct
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.logout.api.direct.DirectLogoutEvents 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.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.api.direct.DirectLogoutView
import io.element.android.features.logout.impl.ui.LogoutActionDialog 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 io.element.android.libraries.di.SessionScope
import javax.inject.Inject 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 package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction 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 io.element.android.tests.testutils.pressTag
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -41,16 +43,11 @@ class LogoutViewTest {
@Test @Test
fun `clicking on logout sends a LogoutEvents`() { fun `clicking on logout sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.action_signout) rule.clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(LogoutEvents.Logout(false)) eventsRecorder.assertSingle(LogoutEvents.Logout(false))
} }
@ -58,17 +55,12 @@ class LogoutViewTest {
@Test @Test
fun `confirming logout sends a LogoutEvents`() { fun `confirming logout sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( logoutAction = AsyncAction.Confirming,
logoutAction = AsyncAction.Confirming, eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.pressTag(TestTags.dialogPositive.value) rule.pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(LogoutEvents.Logout(false)) eventsRecorder.assertSingle(LogoutEvents.Logout(false))
} }
@ -77,16 +69,12 @@ class LogoutViewTest {
fun `clicking on back invoke back callback`() { fun `clicking on back invoke back callback`() {
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onBackClicked = callback,
onChangeRecoveryKeyClicked = EnsureNeverCalled(), )
onBackClicked = callback,
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.pressBack() rule.pressBack()
} }
} }
@ -94,17 +82,12 @@ class LogoutViewTest {
@Test @Test
fun `clicking on confirm after error sends a LogoutEvents`() { fun `clicking on confirm after error sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
logoutAction = AsyncAction.Failure(Exception("Failed to logout")), eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.action_signout_anyway) rule.clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(LogoutEvents.Logout(true)) eventsRecorder.assertSingle(LogoutEvents.Logout(true))
} }
@ -112,17 +95,12 @@ class LogoutViewTest {
@Test @Test
fun `clicking on cancel after error sends a LogoutEvents`() { fun `clicking on cancel after error sends a LogoutEvents`() {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
logoutAction = AsyncAction.Failure(Exception("Failed to logout")), eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onChangeRecoveryKeyClicked = EnsureNeverCalled(),
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(LogoutEvents.CloseDialogs) eventsRecorder.assertSingle(LogoutEvents.CloseDialogs)
} }
@ -132,17 +110,13 @@ class LogoutViewTest {
val data = "data" val data = "data"
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnceWithParam<String?>(data) { callback -> ensureCalledOnceWithParam<String?>(data) { callback ->
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( logoutAction = AsyncAction.Success(data),
logoutAction = AsyncAction.Success(data), eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onSuccessLogout = callback,
onChangeRecoveryKeyClicked = EnsureNeverCalled(), )
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = callback,
)
}
} }
} }
@ -150,18 +124,30 @@ class LogoutViewTest {
fun `last session setting button invoke onChangeRecoveryKeyClicked`() { fun `last session setting button invoke onChangeRecoveryKeyClicked`() {
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setContent { rule.setLogoutView(
LogoutView( aLogoutState(
aLogoutState( isLastDevice = true,
isLastDevice = true, eventSink = eventsRecorder
eventSink = eventsRecorder ),
), onChangeRecoveryKeyClicked = callback,
onChangeRecoveryKeyClicked = callback, )
onBackClicked = EnsureNeverCalled(),
onSuccessLogout = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.common_settings) 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 package io.element.android.features.preferences.impl.root
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.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -37,9 +36,3 @@ fun aPreferencesRootState() = PreferencesRootState(
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(), 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.featureflag.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)

View file

@ -17,7 +17,9 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.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.features.roomdetails.impl.members.details.aRoomMemberDetailsState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -29,18 +31,18 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
override val values: Sequence<RoomDetailsState> override val values: Sequence<RoomDetailsState>
get() = sequenceOf( get() = sequenceOf(
aRoomDetailsState(), aRoomDetailsState(),
aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden), aRoomDetailsState(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic), aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState().copy(isEncrypted = false), aRoomDetailsState(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null), aRoomDetailsState(roomAlias = null),
aDmRoomDetailsState().copy(roomName = "Daniel"), aDmRoomDetailsState(),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), aDmRoomDetailsState(isDmMemberIgnored = true),
aRoomDetailsState().copy(canInvite = true), aRoomDetailsState(canInvite = true),
aRoomDetailsState().copy(isFavorite = true), aRoomDetailsState(isFavorite = true),
aRoomDetailsState().copy( aRoomDetailsState(
canEdit = true, canEdit = true,
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed // 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 // Add other state here
) )
@ -68,32 +70,61 @@ fun aDmRoomMember(
role = role, role = role,
) )
fun aRoomDetailsState() = RoomDetailsState( fun aRoomDetailsState(
roomId = "a room id", roomId: String = "a room id",
roomName = "Marketing", roomName: String = "Marketing",
roomAlias = "#marketing:domain.com", roomAlias: String? = "#marketing:domain.com",
roomAvatarUrl = null, roomAvatarUrl: String? = null,
roomTopic = RoomTopicState.ExistingTopic( roomTopic: RoomTopicState = RoomTopicState.ExistingTopic(
"Welcome to #marketing, home of the Marketing team " + "Welcome to #marketing, home of the Marketing team " +
"|| WIKI PAGE: https://domain.org/wiki/Marketing " + "|| WIKI PAGE: https://domain.org/wiki/Marketing " +
"|| MAIL iki/Marketing " + "|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " + "|| MAI iki/Marketing " +
"|| MAI iki/Marketing..." "|| MAI iki/Marketing..."
), ),
memberCount = 32, memberCount: Long = 32,
isEncrypted = true, isEncrypted: Boolean = true,
canInvite = false, canInvite: Boolean = false,
canEdit = false, canEdit: Boolean = false,
canShowNotificationSettings = true, canShowNotificationSettings: Boolean = true,
roomType = RoomDetailsType.Room, roomType: RoomDetailsType = RoomDetailsType.Room,
roomMemberDetailsState = null, roomMemberDetailsState: RoomMemberDetailsState? = null,
leaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false), roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
isFavorite = false, isFavorite: Boolean = false,
eventSink = {} 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)), roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)),
roomMemberDetailsState = aRoomMemberDetailsState() 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.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection 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.ClickableLinkText
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName 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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -221,7 +224,7 @@ private fun RoomDetailsTopBar(
actions = { actions = {
if (showEdit) { if (showEdit) {
IconButton(onClick = { showMenu = !showMenu }) { IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, "") Icon(Icons.Default.MoreVert, stringResource(id = CommonStrings.a11y_user_menu))
} }
DropdownMenu( DropdownMenu(
expanded = showMenu, expanded = showMenu,
@ -299,6 +302,7 @@ private fun RoomHeaderSection(
modifier = Modifier modifier = Modifier
.size(70.dp) .size(70.dp)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar)
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
@ -463,6 +467,7 @@ internal fun RoomDetailsPreview(@PreviewParameter(RoomDetailsStateProvider::clas
internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview(state: RoomDetailsState) { private fun ContentToPreview(state: RoomDetailsState) {
RoomDetailsView( 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable @Composable
fun RoomMemberHeaderSection( fun RoomMemberHeaderSection(
@ -53,6 +55,7 @@ fun RoomMemberHeaderSection(
modifier = Modifier modifier = Modifier
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.fillMaxSize() .fillMaxSize()
.testTag(TestTags.memberDetailAvatar)
) )
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))

View file

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

View file

@ -17,10 +17,10 @@
package io.element.android.features.roomdetails.impl.notificationsettings package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> { internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState> override val values: Sequence<RoomNotificationSettingsState>
@ -29,7 +29,7 @@ internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParamet
showUserDefinedSettingStyle = false, showUserDefinedSettingStyle = false,
roomName = "Room 1", roomName = "Room 1",
AsyncData.Success( AsyncData.Success(
RoomNotificationSettings( aRoomNotificationSettings(
mode = RoomNotificationMode.MUTE, mode = RoomNotificationMode.MUTE,
isDefault = false 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") 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. * 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. * Shortcut for [<T, R> ensureCalledOnceWithParam] with Unit result.
*/ */

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5951e2e78e261693bdcc71fb1cd205082589fda4b5a73196c6ca3f25c02bf502
size 20332

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f5ff11049c6b3fd510015a17fa5e2e016a0cc715f3d4f68850771a20583dbe0
size 13185

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c79bb3727271a550fdaf7a16d3189e707a578781bc3f77af660e9c935444a6e
size 18862

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753
size 4464

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bea5d8cc4a49aa2ef9ce3b40b716ce448ce8b3afbdb3a8344ee7e8de9e03fbd8
size 16895

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8cbceba31af038cb3bed88419a425b2e54b72934969f11c3c1e5901780b93a08
size 10768

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee8cef67513d24b7d8a2d8e0098a63ab3dfc891adc0f1aed43aa05a9a6c5de84
size 15528

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753
size 4464