diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b7b69bb26f..bcbfac1d7f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,7 +82,8 @@ jobs: - name: ☂️ Upload coverage reports to codecov if: always() uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} # with: # files: build/reports/kover/xml/report.xml diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 897dc36faa..a2960399e9 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -94,6 +94,7 @@ dependencies { testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.libraries.testtags) testImplementation(libs.test.mockk) testImplementation(libs.test.junitext) testImplementation(libs.test.robolectric) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt index 2fdcb947b4..cf02664a98 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -22,16 +22,27 @@ sealed interface TimelineEvents { data object LoadMore : TimelineEvents data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents data class OnScrollFinished(val firstIndex: Int) : TimelineEvents + + /** + * Events coming from a timeline item. + */ + sealed interface EventFromTimelineItem : TimelineEvents + + /** + * Events coming from a poll item. + */ + sealed interface TimelineItemPollEvents : EventFromTimelineItem + data class PollAnswerSelected( val pollStartId: EventId, val answerId: String - ) : TimelineEvents + ) : TimelineItemPollEvents data class PollEndClicked( val pollStartId: EventId, - ) : TimelineEvents + ) : TimelineItemPollEvents data class PollEditClicked( val pollStartId: EventId, - ) : TimelineEvents + ) : TimelineItemPollEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index cb82c07b7a..c044d96f5c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -155,12 +155,12 @@ class TimelinePresenter @AssistedInject constructor( LaunchedEffect(Unit) { combine(timeline.timelineItems, room.membersStateFlow) { items, membersState -> - timelineItemsFactory.replaceWith( - timelineItems = items, - roomMembers = membersState.roomMembers().orEmpty() - ) - items - } + timelineItemsFactory.replaceWith( + timelineItems = items, + roomMembers = membersState.roomMembers().orEmpty() + ) + items + } .onEach { timelineItems -> if (timelineItems.isEmpty()) { paginateBackwards() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index cb7ce3c938..449e13e314 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -45,24 +45,36 @@ import kotlinx.collections.immutable.toPersistentList import java.util.UUID import kotlin.random.Random -fun aTimelineState(timelineItems: ImmutableList = persistentListOf()) = TimelineState( +fun aTimelineState( + timelineItems: ImmutableList = persistentListOf(), + paginationState: MatrixTimeline.PaginationState = aPaginationState(), + eventSink: (TimelineEvents) -> Unit = {}, +) = TimelineState( timelineItems = timelineItems, timelineRoomInfo = aTimelineRoomInfo(), + paginationState = paginationState, renderReadReceipts = false, - paginationState = MatrixTimeline.PaginationState( - isBackPaginating = false, - hasMoreToLoadBackwards = true, - beginningOfRoomReached = false, - ), highlightedEventId = null, newEventState = NewEventState.None, sessionState = aSessionState( isSessionVerified = true, isKeyBackupEnabled = true, ), - eventSink = {}, + eventSink = eventSink, ) +fun aPaginationState( + isBackPaginating: Boolean = false, + hasMoreToLoadBackwards: Boolean = true, + beginningOfRoomReached: Boolean = false, +): MatrixTimeline.PaginationState { + return MatrixTimeline.PaginationState( + isBackPaginating = isBackPaginating, + hasMoreToLoadBackwards = hasMoreToLoadBackwards, + beginningOfRoomReached = beginningOfRoomReached, + ) +} + internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList { return persistentListOf( // 3 items (First Middle Last) with isMine = false diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index bbc36b3d3f..98200e77bd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -127,7 +127,7 @@ fun TimelineItemEventRow( onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onReadReceiptClick: (event: TimelineItem.Event) -> Unit, onSwipeToReply: () -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier ) { val coroutineScope = rememberCoroutineScope() @@ -269,7 +269,7 @@ private fun TimelineItemEventRowContent( onReactionLongClicked: (emoji: String) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMentionClicked: (Mention) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, ) { fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) { @@ -414,7 +414,7 @@ private fun MessageEventBubbleContent( inReplyToClick: () -> Unit, onTimestampClicked: () -> Unit, onMentionClicked: (Mention) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, @SuppressLint("ModifierParameter") // need to rename this modifier to prevent linter false positives @Suppress("ModifierNaming") diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index c3d1f32ef2..a2a691cbcf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -56,7 +56,7 @@ fun TimelineItemGroupedEventsRow( onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier ) { val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) } @@ -107,7 +107,7 @@ private fun TimelineItemGroupedEventsRowContent( onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier.animateContentSize()) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 899d38eb12..adf37d6d8f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -44,7 +44,7 @@ internal fun TimelineItemRow( onReadReceiptClick: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier ) { when (timelineItem) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index af8ed484bc..e0132ea75f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -53,7 +53,7 @@ fun TimelineItemStateEventRow( onClick: () -> Unit, onLongClick: () -> Unit, onReadReceiptsClick: (event: TimelineItem.Event) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index e7dbf678af..d0aae12beb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -43,7 +43,7 @@ import io.element.android.libraries.architecture.Presenter fun TimelineItemEventContentView( content: TimelineItemEventContent, onLinkClicked: (url: String) -> Unit, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {}, ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index 91dd5b99d3..47f4aa7da6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -31,7 +31,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun TimelineItemPollView( content: TimelineItemPollContent, - eventSink: (TimelineEvents) -> Unit, + eventSink: (TimelineEvents.TimelineItemPollEvents) -> Unit, modifier: Modifier = Modifier, ) { fun onAnswerSelected(pollStartId: EventId, answerId: String) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt new file mode 100644 index 0000000000..f80f4ce4c2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -0,0 +1,83 @@ +/* + * 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.timeline + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams +import io.element.android.tests.testutils.EventsRecorder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `reaching the end of the timeline with more events to load emits a LoadMore event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + TimelineView( + aTimelineState( + eventSink = eventsRecorder, + paginationState = aPaginationState( + hasMoreToLoadBackwards = true, + ) + ), + roomName = null, + onUserDataClicked = EnsureNeverCalledWithParam(), + onMessageClicked = EnsureNeverCalledWithParam(), + onMessageLongClicked = EnsureNeverCalledWithParam(), + onTimestampClicked = EnsureNeverCalledWithParam(), + onSwipeToReply = EnsureNeverCalledWithParam(), + onReactionClicked = EnsureNeverCalledWithTwoParams(), + onReactionLongClicked = EnsureNeverCalledWithTwoParams(), + onMoreReactionsClicked = EnsureNeverCalledWithParam(), + onReadReceiptClick = EnsureNeverCalledWithParam(), + ) + } + eventsRecorder.assertSingle(TimelineEvents.LoadMore) + } + + @Test + fun `reaching the end of the timeline does not send a LoadMore event`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setContent { + TimelineView( + aTimelineState( + eventSink = eventsRecorder, + paginationState = aPaginationState( + hasMoreToLoadBackwards = false, + ) + ), + roomName = null, + onUserDataClicked = EnsureNeverCalledWithParam(), + onMessageClicked = EnsureNeverCalledWithParam(), + onMessageLongClicked = EnsureNeverCalledWithParam(), + onTimestampClicked = EnsureNeverCalledWithParam(), + onSwipeToReply = EnsureNeverCalledWithParam(), + onReactionClicked = EnsureNeverCalledWithTwoParams(), + onReactionLongClicked = EnsureNeverCalledWithTwoParams(), + onMoreReactionsClicked = EnsureNeverCalledWithParam(), + onReadReceiptClick = EnsureNeverCalledWithParam(), + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt new file mode 100644 index 0000000000..4ea16a26b0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -0,0 +1,98 @@ +/* + * 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.timeline.components.event + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineItemPollViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `answering a poll with first answer should emit a PollAnswerSelected event`() { + testAnswer(answerIndex = 0) + } + + @Test + fun `answering a poll with second answer should emit a PollAnswerSelected event`() { + testAnswer(answerIndex = 1) + } + + private fun testAnswer(answerIndex: Int) { + val eventsRecorder = EventsRecorder() + val content = aTimelineItemPollContent() + rule.setContent { + TimelineItemPollView( + content = content, + eventSink = eventsRecorder + ) + } + val answer = content.answerItems[answerIndex].answer + rule.onNode(hasText(answer.text)).performClick() + eventsRecorder.assertSingle(TimelineEvents.PollAnswerSelected(content.eventId!!, answer.id)) + } + + @Test + fun `editing a poll should emit a PollEditClicked event`() { + val eventsRecorder = EventsRecorder() + val content = aTimelineItemPollContent( + isMine = true, + isEditable = true, + ) + rule.setContent { + TimelineItemPollView( + content = content, + eventSink = eventsRecorder + ) + } + rule.clickOn(CommonStrings.action_edit_poll) + eventsRecorder.assertSingle(TimelineEvents.PollEditClicked(content.eventId!!)) + } + + @Test + fun `closing a poll should emit a PollEndClicked event`() { + val eventsRecorder = EventsRecorder() + val content = aTimelineItemPollContent( + isMine = true, + ) + rule.setContent { + TimelineItemPollView( + content = content, + eventSink = eventsRecorder + ) + } + rule.clickOn(CommonStrings.action_end_poll) + // A confirmation dialog should be shown + eventsRecorder.assertEmpty() + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(TimelineEvents.PollEndClicked(content.eventId!!)) + } +} 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 e98c025238..870806d424 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 @@ -27,3 +27,9 @@ class EnsureNeverCalledWithParam : (T) -> Unit { 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") + } +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt index 4cc9bd078c..3a1c4babff 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt @@ -31,6 +31,10 @@ class EventsRecorder( } } + fun assertEmpty() { + assertThat(events).isEmpty() + } + fun assertSingle(event: T) { assertList(listOf(event)) }