diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index a2960399e9..dbfc80d820 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.uiUtils) + implementation(projects.libraries.testtags) implementation(projects.features.networkmonitor.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 34f076b5b9..9e36086c45 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -17,13 +17,16 @@ package io.element.android.features.messages.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState @@ -93,10 +96,14 @@ fun aMessagesState( mode = MessageComposerMode.Normal, ), voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(), + actionListState: ActionListState = anActionListState(), + customReactionState: CustomReactionState = aCustomReactionState(), + reactionSummaryState: ReactionSummaryState = aReactionSummaryState(), hasNetworkConnection: Boolean = true, showReinvitePrompt: Boolean = false, enableVoiceMessages: Boolean = true, callState: RoomCallState = RoomCallState.ENABLED, + eventSink: (MessagesEvents) -> Unit = {}, ) = MessagesState( roomId = RoomId("!id:domain"), roomName = roomName, @@ -118,16 +125,9 @@ fun aMessagesState( selectedEvent = null, eventSink = {}, ), - actionListState = anActionListState(), - customReactionState = CustomReactionState( - target = CustomReactionState.Target.None, - eventSink = {}, - selectedEmoji = persistentSetOf(), - ), - reactionSummaryState = ReactionSummaryState( - target = null, - eventSink = {}, - ), + actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, hasNetworkConnection = hasNetworkConnection, snackbarMessage = null, inviteProgress = AsyncData.Uninitialized, @@ -136,5 +136,21 @@ fun aMessagesState( enableVoiceMessages = enableVoiceMessages, callState = callState, appName = "Element", - eventSink = {} + eventSink = eventSink, +) + +fun aReactionSummaryState( + target: ReactionSummaryState.Summary? = null, + eventSink: (ReactionSummaryEvents) -> Unit = {} +) = ReactionSummaryState( + target = target, + eventSink = eventSink, +) + +fun aCustomReactionState( + eventSink: (CustomReactionEvents) -> Unit = {}, +) = CustomReactionState( + target = CustomReactionState.Target.None, + selectedEmoji = persistentSetOf(), + eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt index e33f54de3e..10952058b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -122,9 +122,12 @@ open class ActionListStateProvider : PreviewParameterProvider { } } -fun anActionListState() = ActionListState( - target = ActionListState.Target.None, - eventSink = {} +fun anActionListState( + target: ActionListState.Target = ActionListState.Target.None, + eventSink: (ActionListEvents) -> Unit = {}, +) = ActionListState( + target = target, + eventSink = eventSink ) fun aTimelineItemActionList(): ImmutableList { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 3db91fd67c..44d2d6ee4c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -86,6 +87,7 @@ import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -101,28 +103,52 @@ fun ActionListView( val targetItem = (state.target as? ActionListState.Target.Success)?.event fun onItemActionClicked( - itemAction: TimelineItemAction + itemAction: TimelineItemAction, + immediate: Boolean, ) { if (targetItem == null) return - sheetState.hide(coroutineScope) { + if (immediate) { + coroutineScope.launch { sheetState.hide() } state.eventSink(ActionListEvents.Clear) onActionSelected(itemAction, targetItem) + } else { + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onActionSelected(itemAction, targetItem) + } } } - fun onEmojiReactionClicked(emoji: String) { + fun onEmojiReactionClicked( + emoji: String, + immediate: Boolean, + ) { if (targetItem == null) return - sheetState.hide(coroutineScope) { + if (immediate) { + coroutineScope.launch { sheetState.hide() } state.eventSink(ActionListEvents.Clear) onEmojiReactionClicked(emoji, targetItem) + } else { + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onEmojiReactionClicked(emoji, targetItem) + } } } - fun onCustomReactionClicked() { + fun onCustomReactionClicked( + immediate: Boolean, + ) { if (targetItem == null) return - sheetState.hide(coroutineScope) { + if (immediate) { + coroutineScope.launch { sheetState.hide() } state.eventSink(ActionListEvents.Clear) onCustomReactionClicked(targetItem) + } else { + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onCustomReactionClicked(targetItem) + } } } @@ -136,11 +162,18 @@ fun ActionListView( onDismissRequest = ::onDismiss, modifier = modifier, ) { + val immediate = LocalInspectionMode.current SheetContent( state = state, - onActionClicked = ::onItemActionClicked, - onEmojiReactionClicked = ::onEmojiReactionClicked, - onCustomReactionClicked = ::onCustomReactionClicked, + onActionClicked = { + onItemActionClicked(it, immediate) + }, + onEmojiReactionClicked = { + onEmojiReactionClicked(it, immediate) + }, + onCustomReactionClicked = { + onCustomReactionClicked(immediate) + }, modifier = Modifier .navigationBarsPadding() .imePadding() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 14bd831efc..e7ca8116f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -46,6 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag private val BUBBLE_RADIUS = 12.dp internal val BUBBLE_INCOMING_OFFSET = 16.dp @@ -115,6 +117,7 @@ fun MessageEventBubble( ) { Surface( modifier = Modifier + .testTag(TestTags.messageBubble) .widthIn(min = 80.dp) .clip(bubbleShape) .combinedClickable( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt new file mode 100644 index 0000000000..1f4ba8bf73 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -0,0 +1,286 @@ +/* + * 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.messages.impl + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithParamAndResult +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MessagesViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onBackPressed = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on room name invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onRoomDetailsClicked = callback, + ) + rule.onNodeWithText(state.roomName.dataOrNull().orEmpty()).performClick() + } + } + + @Test + fun `clicking on join call invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onJoinCallClicked = callback, + ) + val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call) + rule.onNodeWithContentDescription(joinCallContentDescription).performClick() + } + } + + @Test + fun `clicking on an Event invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() + val callback = EnsureCalledOnceWithParam( + expectedParam = timelineItem, + result = true, + ) + rule.setMessagesView( + state = state, + onEventClicked = callback, + ) + // Cannot perform click on "Text", it's not detected. Use tag instead + rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() + callback.assertSuccess() + } + + @Test + fun `clicking on send location invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + composerState = aMessageComposerState( + showAttachmentSourcePicker = true + ), + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onSendLocationClicked = callback, + ) + rule.clickOn(R.string.screen_room_attachment_source_location) + } + } + + @Test + fun `clicking on create poll invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + composerState = aMessageComposerState( + showAttachmentSourcePicker = true + ), + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onCreatePollClicked = callback, + ) + // Then click on the poll action + rule.clickOn(R.string.screen_room_attachment_source_poll) + } + } + + @Test + fun `clicking on the sender of an Event invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() + ensureCalledOnceWithParam( + param = (timelineItem as TimelineItem.Event).senderId + ) { callback -> + rule.setMessagesView( + state = state, + onUserDataClicked = callback, + ) + val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty() + rule.onNodeWithText(senderName).performClick() + } + } + + @Test + fun `selecting a action on a message emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val stateWithMessageAction = state.copy( + actionListState = anActionListState( + target = ActionListState.Target.Success( + event = timelineItem, + displayEmojiReactions = true, + actions = persistentListOf(TimelineItemAction.Edit), + ) + ), + ) + rule.setMessagesView( + state = stateWithMessageAction, + ) + rule.clickOn(CommonStrings.action_edit) + eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Edit, timelineItem)) + } + + @Test + fun `clicking on a reaction emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + rule.onAllNodesWithText("👍️").onFirst().performClick() + eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventId!!)) + } + + @Test + fun `long clicking on a reaction emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + reactionSummaryState = aReactionSummaryState( + target = null, + eventSink = eventsRecorder, + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + rule.onAllNodesWithText("👍️").onFirst().performTouchInput { longClick() } + eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️")) + } + + @Test + fun `clicking on more reaction emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + customReactionState = aCustomReactionState( + eventSink = eventsRecorder, + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + val moreReactionContentDescription = rule.activity.getString(R.string.screen_room_timeline_add_reaction) + rule.onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick() + eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem)) + } +} + +private fun AndroidComposeTestRule.setMessagesView( + state: MessagesState, + onBackPressed: () -> Unit = EnsureNeverCalled(), + onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(), + onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), + onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onPreviewAttachments: (ImmutableList) -> Unit = EnsureNeverCalledWithParam(), + onSendLocationClicked: () -> Unit = EnsureNeverCalled(), + onCreatePollClicked: () -> Unit = EnsureNeverCalled(), + onJoinCallClicked: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Cannot use the RichTextEditor, so simulate a LocalInspectionMode + CompositionLocalProvider(LocalInspectionMode provides true) { + MessagesView( + state = state, + onBackPressed = onBackPressed, + onRoomDetailsClicked = onRoomDetailsClicked, + onEventClicked = onEventClicked, + onUserDataClicked = onUserDataClicked, + onPreviewAttachments = onPreviewAttachments, + onSendLocationClicked = onSendLocationClicked, + onCreatePollClicked = onCreatePollClicked, + onJoinCallClicked = onJoinCallClicked, + ) + } + } +} diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 7542194bab..a88f036fd5 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -58,6 +58,11 @@ object TestTags { */ val richTextEditor = TestTag("rich_text_editor") + /** + * Message bubble. + */ + val messageBubble = TestTag("message_bubble") + /** * Dialogs. */ diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt index 870806d424..f2f2c31fed 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EnsureNeverCalled.kt @@ -28,6 +28,12 @@ class EnsureNeverCalledWithParam : (T) -> Unit { } } +class EnsureNeverCalledWithParamAndResult : (T) -> R { + override fun invoke(p1: T): R { + throw AssertionError("Should not be called and is called with $p1") + } +} + class EnsureNeverCalledWithTwoParams : (T, U) -> Unit { override fun invoke(p1: T, p2: U) { throw AssertionError("Should not be called and is called with $p1 and $p2")