Fix and write tests

This commit is contained in:
Benoit Marty
2025-01-21 10:57:43 +01:00
committed by Benoit Marty
parent 13defbbcc0
commit f21aeea980
16 changed files with 952 additions and 266 deletions

View File

@@ -123,6 +123,7 @@ class EventItemFactory @Inject constructor(
duration = null,
),
mediaSource = type.source,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
)
is ImageMessageType -> MediaItem.Image(
id = currentTimelineItem.uniqueId,
@@ -142,7 +143,7 @@ class EventItemFactory @Inject constructor(
duration = null,
),
mediaSource = type.source,
thumbnailSource = null,
thumbnailSource = type.info?.thumbnailSource,
)
is StickerMessageType -> MediaItem.Image(
id = currentTimelineItem.uniqueId,
@@ -162,7 +163,7 @@ class EventItemFactory @Inject constructor(
duration = null,
),
mediaSource = type.source,
thumbnailSource = null,
thumbnailSource = type.info?.thumbnailSource,
)
is VideoMessageType -> MediaItem.Video(
id = currentTimelineItem.uniqueId,

View File

@@ -7,6 +7,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
@@ -35,6 +36,7 @@ interface MediaGalleryDataSource {
}
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class TimelineMediaGalleryDataSource @Inject constructor(
private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
@@ -62,7 +64,9 @@ class TimelineMediaGalleryDataSource @Inject constructor(
timeline = it
emit(it)
},
{ groupedMediaItemsFlow.emit(AsyncData.Failure(it)) },
{
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
},
)
}.flatMapLatest { timeline ->
timeline.timelineItems.onEach {

View File

@@ -40,7 +40,7 @@ import kotlinx.coroutines.launch
class MediaGalleryPresenter @AssistedInject constructor(
@Assisted private val navigator: MediaGalleryNavigator,
private val room: MatrixRoom,
private val mediaGalleryDataSource: TimelineMediaGalleryDataSource,
private val mediaGalleryDataSource: MediaGalleryDataSource,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,

View File

@@ -18,6 +18,7 @@ fun aMediaItemImage(
id: UniqueId = UniqueId("imageId"),
eventId: EventId? = null,
senderId: UserId? = null,
mediaSourceUrl: String = "",
): MediaItem.Image {
return MediaItem.Image(
id = id,
@@ -25,7 +26,7 @@ fun aMediaItemImage(
mediaInfo = anImageMediaInfo(
senderId = senderId,
),
mediaSource = MediaSource(""),
mediaSource = MediaSource(mediaSourceUrl),
thumbnailSource = null,
)
}

View File

@@ -41,7 +41,6 @@ class MediaViewerDataSource(
private val mediaLoader: MatrixMediaLoader,
private val localMediaFactory: LocalMediaFactory,
) {
// List of media files that are currently being loaded
private val mediaFiles: MutableList<MediaFile> = mutableListOf()
@@ -145,6 +144,4 @@ class MediaViewerDataSource(
localMediaState.value = AsyncData.Failure(it)
}
}
}

View File

@@ -266,7 +266,6 @@ private fun MediaViewerPage(
onShowOverlayChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val currentShowOverlay by rememberUpdatedState(showOverlay)
val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
@@ -489,7 +488,7 @@ private fun MediaViewerTopBar(
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
contentDescription = stringResource(id = CommonStrings.a11y_view_details),
)
}
}

View File

@@ -165,6 +165,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource(""),
)
@@ -214,6 +215,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource(""),
thumbnailSource = null,
@@ -260,6 +262,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource(""),
)
@@ -310,10 +313,10 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = "2:03",
),
mediaSource = MediaSource(""),
thumbnailSource = null,
duration = "2:03",
)
)
}
@@ -361,10 +364,9 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = listOf(1f, 2f).toImmutableList(),
duration = "7:36",
),
mediaSource = MediaSource(""),
duration = "7:36",
waveform = listOf(1f, 2f).toImmutableList(),
)
)
}
@@ -412,6 +414,7 @@ class DefaultEventItemFactoryTest {
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null,
),
mediaSource = MediaSource(""),
thumbnailSource = null,

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeMediaGalleryDataSource(
private val startLambda: () -> Unit = { lambdaError() },
private val loadMoreLambda: (Timeline.PaginationDirection) -> Unit = { lambdaError() },
private val deleteItemLambda: (EventId) -> Unit = { lambdaError() },
) : MediaGalleryDataSource {
override fun start() = startLambda()
private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(
replay = 1
)
override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> {
return groupedMediaItemsFlow
}
suspend fun emitGroupedMediaItems(groupedMediaItems: AsyncData<GroupedMediaItems>) {
groupedMediaItemsFlow.emit(groupedMediaItems)
}
override fun getLastData(): AsyncData<GroupedMediaItems> {
return groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
}
override suspend fun loadMore(direction: Timeline.PaginationDirection) {
loadMoreLambda(direction)
}
override suspend fun deleteItem(eventId: EventId) {
deleteItemLambda(eventId)
}
}

View File

@@ -8,12 +8,12 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import android.net.Uri
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -25,15 +25,11 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -47,49 +43,37 @@ class MediaGalleryPresenterTest {
@Test
fun `present - initial state`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val startLambda = lambdaRecorder<Unit> { }
val presenter = createMediaGalleryPresenter(
navigator = navigator,
mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = startLambda,
),
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(),
)
)
assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue()
assertThat(initialState.snackbarMessage).isNull()
}
startLambda.assertions().isCalledOnce()
}
@Test
fun `present - change mode`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaGalleryNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaGalleryPresenter(
navigator = navigator,
room = FakeMatrixRoom(
displayName = A_ROOM_NAME,
mediaTimelineResult = { Result.success(FakeTimeline()) },
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
val state = awaitItem()
@@ -110,7 +94,7 @@ class MediaGalleryPresenterTest {
`present - bottom sheet state - own message`(canDeleteOwn = false)
}
private suspend fun TestScope.`present - bottom sheet state - own message`(canDeleteOwn: Boolean) {
private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) {
val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom(
sessionId = A_USER_ID,
@@ -120,8 +104,7 @@ class MediaGalleryPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
val item = aMediaItemImage(
eventId = AN_EVENT_ID,
@@ -154,7 +137,7 @@ class MediaGalleryPresenterTest {
`present - bottom sheet state - other message`(canDeleteOther = false)
}
private suspend fun TestScope.`present - bottom sheet state - other message`(canDeleteOther: Boolean) {
private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) {
val presenter = createMediaGalleryPresenter(
room = FakeMatrixRoom(
sessionId = A_USER_ID,
@@ -164,8 +147,7 @@ class MediaGalleryPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
val item = aMediaItemImage(
eventId = AN_EVENT_ID,
@@ -197,8 +179,7 @@ class MediaGalleryPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
// Delete bottom sheet
val item = aMediaItemImage()
initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
@@ -217,6 +198,42 @@ class MediaGalleryPresenterTest {
}
}
@Test
fun `present - delete item`() = runTest {
val deleteItemLambda = lambdaRecorder<EventId, Unit> { }
val presenter = createMediaGalleryPresenter(
mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
deleteItemLambda = deleteItemLambda,
),
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID))
deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@Test
fun `present - share item`() = runTest {
val presenter = createMediaGalleryPresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
}
// TODO Add more test on this part
}
@Test
fun `present - save on disk`() = runTest {
val presenter = createMediaGalleryPresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
}
// TODO Add more test on this part
}
@Test
fun `present - view in timeline invokes the navigator`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
@@ -230,15 +247,37 @@ class MediaGalleryPresenterTest {
navigator = navigator,
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
private fun TestScope.createMediaGalleryPresenter(
@Test
fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
val presenter = createMediaGalleryPresenter(
mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
loadMoreLambda = loadMoreLambda,
),
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
private fun createMediaGalleryPresenter(
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(),
@@ -249,22 +288,11 @@ class MediaGalleryPresenterTest {
return MediaGalleryPresenter(
navigator = navigator,
room = room,
timelineMediaItemsFactory = TimelineMediaItemsFactory(
dispatchers = testCoroutineDispatchers(),
virtualItemFactory = VirtualItemFactory(
dateFormatter = FakeDateFormatter(),
),
eventItemFactory = EventItemFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
),
),
mediaGalleryDataSource = mediaGalleryDataSource,
localMediaFactory = localMediaFactory,
mediaLoader = matrixMediaLoader,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
}
}

View File

@@ -8,9 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
@@ -42,27 +40,6 @@ class MediaItemsPostProcessorTest {
private val date3 = aMediaItemDateSeparator(id = UniqueId("3"))
private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1"))
@Test
fun `process Uninitialized`() {
val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Uninitialized)
assertThat(result).isEqualTo(AsyncData.Uninitialized)
}
@Test
fun `process Loading`() {
val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Loading())
assertThat(result).isEqualTo(AsyncData.Loading<GroupedMediaItems>())
}
@Test
fun `process Failure`() {
val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Failure(AN_EXCEPTION))
assertThat(result).isEqualTo(AsyncData.Failure<GroupedMediaItems>(AN_EXCEPTION))
}
@Test
fun `process Empty`() {
test(
@@ -215,19 +192,16 @@ class MediaItemsPostProcessorTest {
expectedFileItems: List<MediaItem>,
) {
val sut = MediaItemsPostProcessor()
val result = sut.process(AsyncData.Success(mediaItems.toImmutableList()))
val data = result.dataOrNull()!!
val result = sut.process(mediaItems.toImmutableList())
// Compare the lists to have better failure info
assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems)
assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems)
assertThat(result).isEqualTo(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
fileItems = expectedFileItems.toImmutableList(),
)
GroupedMediaItems(
imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
fileItems = expectedFileItems.toImmutableList(),
)
)
}

View File

@@ -0,0 +1,279 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class TimelineMediaGalleryDataSourceTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `test - not started TimelineMediaGalleryDataSource emits no events`() {
val fakeTimeline = FakeTimeline()
runTest {
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
sut.groupedMediaItemsFlow().test {
// Also, loadMore and deleteItem should be no-op
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
sut.deleteItem(AN_EVENT_ID)
expectNoEvents()
}
}
}
@Test
fun `test - getLastData should return the previous emitted data`() {
val fakeTimeline = FakeTimeline()
runTest {
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
sut.start()
assertThat(sut.getLastData()).isEqualTo(AsyncData.Uninitialized)
sut.groupedMediaItemsFlow().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(sut.getLastData().isLoading()).isTrue()
assertThat(awaitItem()).isEqualTo(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(),
)
)
)
assertThat(sut.getLastData().isSuccess()).isTrue()
// Also test that starting again should have no effect
sut.start()
}
}
// Ensure that the timeline has been closed on flow completion
assertThat(fakeTimeline.closeCounter).isEqualTo(1)
}
@Test
fun `test - load more should call the timeline paginate method`() = runTest {
val paginateLambdaRecorder =
lambdaRecorder<Timeline.PaginationDirection, Result<Boolean>> { _ ->
Result.success(true)
}
val fakeTimeline = FakeTimeline().apply {
paginateLambda = paginateLambdaRecorder
}
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
sut.start()
sut.groupedMediaItemsFlow().test {
skipItems(2)
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
paginateLambdaRecorder.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
}
}
@Test
fun `test - delete item should call the timeline redact method`() = runTest {
val redactEventLambdaRecorder =
lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
Result.success(Unit)
}
val fakeTimeline = FakeTimeline().apply {
redactEventLambda = redactEventLambdaRecorder
}
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
sut.start()
sut.groupedMediaItemsFlow().test {
skipItems(2)
sut.deleteItem(AN_EVENT_ID)
redactEventLambdaRecorder.assertions().isCalledOnce().with(
value(AN_EVENT_ID.toEventOrTransactionId()),
value(null),
)
}
}
@Test
fun `test - failing to load timeline should emit an error`() = runTest {
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.failure(AN_EXCEPTION) },
roomCoroutineScope = backgroundScope,
)
)
sut.start()
sut.groupedMediaItemsFlow().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(sut.getLastData().isLoading()).isTrue()
assertThat(awaitItem()).isEqualTo(
AsyncData.Failure<GroupedMediaItems>(AN_EXCEPTION)
)
}
}
@Test
fun `test - when timeline emits new data, the flow emits the data`() = runTest {
val timelineItems = MutableStateFlow<List<MatrixTimelineItem>>(emptyList())
val fakeTimeline = FakeTimeline(
timelineItems = timelineItems,
)
val sut = createTimelineMediaGalleryDataSource(
room = FakeMatrixRoom(
mediaTimelineResult = { Result.success(fakeTimeline) },
roomCoroutineScope = backgroundScope,
)
)
sut.start()
sut.groupedMediaItemsFlow().test {
assertThat(awaitItem().isLoading()).isTrue()
assertThat(sut.getLastData().isLoading()).isTrue()
assertThat(awaitItem()).isEqualTo(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(),
)
)
)
timelineItems.emit(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
content = aMessageContent(
messageType = ImageMessageType(
filename = "body.jpg",
caption = "body.jpg caption",
formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
source = MediaSource("url"),
info = ImageInfo(
height = 10L,
width = 5L,
mimetype = MimeTypes.Jpeg,
size = 888L,
thumbnailInfo = ThumbnailInfo(
height = 10L,
width = 5L,
mimetype = MimeTypes.Jpeg,
size = 111L,
),
thumbnailSource = MediaSource("url_thumbnail"),
blurhash = A_BLUR_HASH,
)
)
)
),
)
)
)
assertThat(awaitItem()).isEqualTo(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(
MediaItem.Image(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
filename = "body.jpg",
caption = "body.jpg caption",
mimeType = MimeTypes.Jpeg,
formattedFileSize = "888 Bytes",
fileExtension = "jpg",
senderId = A_USER_ID,
senderName = "alice",
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
duration = null
),
mediaSource = MediaSource("url"),
thumbnailSource = MediaSource("url_thumbnail"),
)
),
fileItems = persistentListOf()
)
)
)
}
}
private fun TestScope.createTimelineMediaGalleryDataSource(
room: MatrixRoom = FakeMatrixRoom(
liveTimeline = FakeTimeline(),
),
): TimelineMediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
timelineMediaItemsFactory = TimelineMediaItemsFactory(
dispatchers = testCoroutineDispatchers(),
virtualItemFactory = VirtualItemFactory(
dateFormatter = FakeDateFormatter(),
),
eventItemFactory = EventItemFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
dateFormatter = FakeDateFormatter(),
),
),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
}
}

View File

@@ -50,6 +50,7 @@ class AndroidLocalMediaFactoryTest {
dateSent = "12:34",
dateSentFull = "full",
waveform = null,
duration = null,
)
)
}

View File

@@ -10,14 +10,14 @@
package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -30,14 +30,22 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -52,6 +60,7 @@ class MediaViewerPresenterTest {
private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
private val aUrl = "aUrl"
@Test
fun `present - initial state null Event`() = runTest {
@@ -61,9 +70,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -79,9 +88,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isFalse()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -97,9 +106,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -116,9 +125,9 @@ class MediaViewerPresenterTest {
)
)
presenter.test {
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
assertThat(initialState.currentIndex).isEqualTo(0)
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.canShowInfo).isTrue()
assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -126,114 +135,280 @@ class MediaViewerPresenterTest {
}
@Test
fun `present - download media success scenario`() = runTest {
val presenter = createMediaViewerPresenter(
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
fun `present - data source update`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
state = awaitItem()
val successData = state.downloadedMedia.dataOrNull()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(successData).isNotNull()
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage()
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitFirstItem()
assertThat(updatedState.listData).hasSize(1)
val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData
assertThat(item.eventId).isNull()
assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo)
assertThat(item.mediaSource).isEqualTo(anImage.mediaSource)
assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource)
assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
fun `present - check all actions`() = runTest {
val mediaActions = FakeLocalMediaActions()
val snackbarDispatcher = SnackbarDispatcher()
val presenter = createMediaViewerPresenter(
localMediaActions = mediaActions,
snackbarDispatcher = snackbarDispatcher,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
fun `present - load media`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
// no state changes while media is loading
state.eventSink(MediaViewerEvents.OpenWith)
state.eventSink(MediaViewerEvents.Share)
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
// Should succeed without change of state
state.eventSink(MediaViewerEvents.OpenWith)
// Should succeed without change of state
state.eventSink(MediaViewerEvents.Share)
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem().snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.LoadMedia(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - download media failure then retry with success scenario`() = runTest {
val matrixMediaLoader = FakeMatrixMediaLoader()
fun `present - open info`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
matrixMediaLoader = matrixMediaLoader,
mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
matrixMediaLoader.shouldFail = true
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java)
matrixMediaLoader.shouldFail = false
failureState.eventSink(MediaViewerEvents.RetryLoading)
// There is one recomposition because of the retry mechanism
skipItems(1)
val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(successData).isNotNull()
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OpenInfo(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withInfoState = awaitItem()
assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
withInfoState.eventSink(
MediaViewerEvents.CloseBottomSheet
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@Test
fun `present - delete media success scenario`() = runTest {
fun `present - clear loading error`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ClearLoadingError(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - share`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.Share(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - save on disk`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.SaveOnDisk(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - open with`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OpenWith(
aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
}
}
@Test
fun `present - delete and cancel`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId = AN_EVENT_ID,
data = aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
withBottomSheetState.eventSink(
MediaViewerEvents.CloseBottomSheet
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
}
}
@Test
fun `present - delete`() = runTest {
val redactEventLambda = lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
Result.success(Unit)
}
@@ -241,26 +416,51 @@ class MediaViewerPresenterTest {
this.redactEventLambda = redactEventLambda
}
val onItemDeletedLambda = lambdaRecorder<Unit> { }
val navigator = FakeMediaViewerNavigator(
onItemDeletedLambda = onItemDeletedLambda,
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
room = FakeMatrixRoom(
liveTimeline = timeline,
canRedactOwnResult = { Result.success(true) },
),
mediaViewerNavigator = navigator,
mediaGalleryDataSource = mediaGalleryDataSource,
mediaViewerNavigator = FakeMediaViewerNavigator(
onItemDeletedLambda = onItemDeletedLambda
)
)
val anImage = aMediaItemImage(
eventId = AN_EVENT_ID,
mediaSourceUrl = aUrl,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID))
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId = AN_EVENT_ID,
data = aMediaViewerPageData(
mediaSource = MediaSource(aUrl)
)
)
)
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
updatedState.eventSink(
MediaViewerEvents.Delete(
eventId = AN_EVENT_ID,
)
)
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
redactEventLambda.assertions()
.isCalledOnce()
.with(
@@ -272,7 +472,71 @@ class MediaViewerPresenterTest {
}
@Test
fun `present - view in timeline invokes the navigator`() = runTest {
fun `present - on navigate to`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
val anImage2 = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage, anImage2),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(1)
)
val finalState = awaitItem()
assertThat(finalState.currentIndex).isEqualTo(1)
}
}
@Test
fun `present - load more`() = runTest {
val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
loadMoreLambda = loadMoreLambda,
)
val presenter = createMediaViewerPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
)
presenter.test {
awaitFirstItem()
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage),
fileItems = persistentListOf(),
)
)
)
val updatedState = awaitItem()
updatedState.eventSink(
MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)
)
loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
}
}
@Test
fun `present - view in timeline hide the bottom sheet and invokes the navigator`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMediaViewerNavigator(
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@@ -285,22 +549,28 @@ class MediaViewerPresenterTest {
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
private fun createMediaViewerPresenter(
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
private fun TestScope.createMediaViewerPresenter(
eventId: EventId? = null,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: MatrixRoom = FakeMatrixRoom(
@@ -309,18 +579,24 @@ class MediaViewerPresenterTest {
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
),
localMediaFactory = localMediaFactory,
mediaLoader = matrixMediaLoader,
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
galleryMode = MediaGalleryMode.Images,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
localMediaFactory = localMediaFactory,
),
room = room,
localMediaActions = localMediaActions,
snackbarDispatcher = snackbarDispatcher,
navigator = mediaViewerNavigator,
room = room,
)
}
}

View File

@@ -18,15 +18,15 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -36,78 +36,127 @@ import org.junit.runner.RunWith
class MediaViewerViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
fun `clicking on back invokes expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMediaViewerView(
aMediaViewerState(
eventSink = eventsRecorder
),
state = state,
onBackClick = callback,
)
rule.pressBack()
}
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `clicking on open emit expected Event`() {
testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith)
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.action_open_with,
MediaViewerEvents.OpenWith(data),
)
}
private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
@Test
fun `clicking on info emit expected Event`() {
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
)
testMenuAction(
data,
CommonStrings.a11y_view_details,
MediaViewerEvents.OpenInfo(data),
)
}
private fun testMenuAction(
data: MediaViewerPageData.MediaViewerData,
contentDescriptionRes: Int,
expectedEvent: MediaViewerEvents,
) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
eventSink = eventsRecorder
),
)
val contentDescription = rule.activity.getString(contentDescriptionRes)
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(expectedEvent)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
expectedEvent,
)
)
}
@Test
fun `clicking on save emit expected Event`() {
testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk)
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_save,
MediaViewerEvents.SaveOnDisk(data),
)
}
@Test
fun `clicking on share emit expected Event`() {
testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share)
val data = aMediaViewerPageData()
testBottomSheetAction(
data,
CommonStrings.action_share,
MediaViewerEvents.Share(data),
)
}
private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
private fun testBottomSheetAction(
data: MediaViewerPageData.MediaViewerData,
contentDescriptionRes: Int,
expectedEvent: MediaViewerEvents,
) {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
eventSink = eventsRecorder
),
)
rule.clickOn(contentDescriptionRes)
eventsRecorder.assertSingle(expectedEvent)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
expectedEvent,
)
)
}
@Test
fun `clicking on image hides the overlay`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder
),
state = state,
)
// Ensure that the action are visible
val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
@@ -120,54 +169,79 @@ class MediaViewerViewTest {
rule.mainClock.advanceTimeBy(1_000)
rule.onNodeWithContentDescription(contentDescription)
.assertDoesNotExist()
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `clicking swipe on the image invokes the expected callback`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val state = aMediaViewerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
mediaInfo = anImageMediaInfo(),
eventSink = eventsRecorder
),
state = state,
onBackClick = callback,
)
val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
rule.mainClock.advanceTimeBy(1_000)
}
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
)
)
}
@Test
fun `error case, click on retry emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.LoadMedia(data),
)
)
}
@Test
fun `error case, click on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<MediaViewerEvents>()
val data = aMediaViewerPageData(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
)
rule.setMediaViewerView(
aMediaViewerState(
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
mediaInfo = anImageMediaInfo(),
listData = listOf(data),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError)
eventsRecorder.assertList(
listOf(
MediaViewerEvents.OnNavigateTo(0),
MediaViewerEvents.LoadMedia(data),
MediaViewerEvents.ClearLoadingError(data)
)
)
}
}

View File

@@ -43,6 +43,7 @@ class FakeLocalMediaFactory(
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
)
return aLocalMedia(uri, mediaInfo)
}

View File

@@ -29,6 +29,7 @@
<string name="a11y_show_password">"Show password"</string>
<string name="a11y_start_call">"Start a call"</string>
<string name="a11y_user_menu">"User menu"</string>
<string name="a11y_view_details">"View details"</string>
<string name="a11y_voice_message_record">"Record voice message."</string>
<string name="a11y_voice_message_stop_recording">"Stop recording"</string>
<string name="action_accept">"Accept"</string>