Media actions: add more tests

This commit is contained in:
ganfra 2023-06-06 22:08:57 +02:00
parent 20eb2ade54
commit 730f50a433
9 changed files with 130 additions and 57 deletions

View file

@ -123,6 +123,10 @@ class MediaViewerPresenter @AssistedInject constructor(
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
} }
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
} }
else -> Unit else -> Unit
} }
@ -133,7 +137,7 @@ class MediaViewerPresenter @AssistedInject constructor(
is Async.Success -> { is Async.Success -> {
localMediaActions.share(localMedia.state) localMediaActions.share(localMedia.state)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(openShareError(it)) val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
} }
} }
@ -146,7 +150,7 @@ class MediaViewerPresenter @AssistedInject constructor(
is Async.Success -> { is Async.Success -> {
localMediaActions.open(localMedia.state) localMediaActions.open(localMedia.state)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(openShareError(it)) val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
} }
} }
@ -154,7 +158,7 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
} }
private fun openShareError(throwable: Throwable): Int { private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) { return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found UtilsR.string.error_no_compatible_app_found
} else { } else {

View file

@ -19,23 +19,39 @@ package io.element.android.features.messages.media
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaActions import io.element.android.features.messages.impl.media.local.LocalMediaActions
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.withContext
class FakeLocalMediaActions: LocalMediaActions { class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions {
var shouldFail = false
@Composable @Composable
override fun Configure() { override fun Configure() {
//NOOP //NOOP
} }
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> { override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
return Result.success(Unit) if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
} }
override suspend fun share(localMedia: LocalMedia): Result<Unit> { override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
return Result.success(Unit) if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
} }
override suspend fun open(localMedia: LocalMedia): Result<Unit> { override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
return Result.success(Unit) if (shouldFail) {
Result.failure(RuntimeException())
} else {
Result.success(Unit)
}
} }
} }

View file

@ -19,7 +19,6 @@
package io.element.android.features.messages.media.viewer package io.element.android.features.messages.media.viewer
import android.net.Uri import android.net.Uri
import androidx.media3.common.MimeTypes
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
@ -32,55 +31,110 @@ import io.element.android.features.messages.media.FakeLocalMediaActions
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG private val TESTED_MEDIA_INFO = MediaInfo(
private const val TESTED_MEDIA_NAME = "MediaName" name = "",
mimeType = "",
formattedFileSize = ""
)
class MediaViewerPresenterTest { class MediaViewerPresenterTest {
private val mockMediaUri: Uri = mockk("localMediaUri") private val mockMediaUri: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
private val mediaLoader = FakeMediaLoader()
@Test @Test
fun `present - download media success scenario`() = runTest { fun `present - download media success scenario`() = runTest {
val presenter = aMediaViewerPresenter() val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() var state = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem() state = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1) state = awaitItem()
val successState = awaitItem() val successData = state.downloadedMedia.dataOrNull()
val successData = successState.downloadedMedia.dataOrNull() assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successData).isNotNull() assertThat(successData).isNotNull()
} }
} }
@Test
fun `present - check all actions `() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(Async.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(Async.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()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
// Check failures
mediaActions.shouldFail = true
state.eventSink(MediaViewerEvents.OpenWith)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.Share)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.snackbarMessage).isNotNull()
state = awaitItem()
assertThat(state.snackbarMessage).isNull()
}
}
@Test @Test
fun `present - download media failure then retry with success scenario`() = runTest { fun `present - download media failure then retry with success scenario`() = runTest {
val presenter = aMediaViewerPresenter() val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false)
val mediaLoader = FakeMediaLoader(coroutineDispatchers)
val mediaActions = FakeLocalMediaActions(coroutineDispatchers)
val presenter = aMediaViewerPresenter(mediaLoader, mediaActions)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
mediaLoader.shouldFail = true mediaLoader.shouldFail = true
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val failureState = awaitItem() val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java) assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
mediaLoader.shouldFail = false mediaLoader.shouldFail = false
@ -89,7 +143,6 @@ class MediaViewerPresenterTest {
skipItems(1) skipItems(1)
val retryLoadingState = awaitItem() val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
val successState = awaitItem() val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull() val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
@ -97,19 +150,19 @@ class MediaViewerPresenterTest {
} }
} }
private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { private fun aMediaViewerPresenter(
mediaLoader: FakeMediaLoader,
localMediaActions: FakeLocalMediaActions,
): MediaViewerPresenter {
return MediaViewerPresenter( return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs( inputs = MediaViewerNode.Inputs(
mediaInfo = MediaInfo(name = TESTED_MEDIA_NAME, mediaInfo = TESTED_MEDIA_INFO,
mimeType = mimeType,
formattedFileSize = "14MB"
),
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
thumbnailSource = null thumbnailSource = null
), ),
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader, mediaLoader = mediaLoader,
localMediaActions = FakeLocalMediaActions(), localMediaActions = localMediaActions,
snackbarDispatcher = SnackbarDispatcher() snackbarDispatcher = SnackbarDispatcher()
) )
} }

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -60,7 +59,7 @@ fun handleSnackbarMessage(
val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null)
LaunchedEffect(snackbarMessage) { LaunchedEffect(snackbarMessage) {
if (snackbarMessage != null) { if (snackbarMessage != null) {
launch(Dispatchers.Main) { launch {
snackbarDispatcher.clear() snackbarDispatcher.clear()
} }
} }

View file

@ -26,4 +26,6 @@ dependencies {
api(projects.libraries.core) api(projects.libraries.core)
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
api(libs.coroutines.core) api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.tests.testutils)
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.test package io.element.android.libraries.matrix.test
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -36,15 +37,18 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
private val userDisplayName: Result<String> = Result.success(A_USER_NAME), private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL), private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(), private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),

View file

@ -16,40 +16,40 @@
package io.element.android.libraries.matrix.test.media package io.element.android.libraries.matrix.test.media
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
class FakeMediaLoader : MatrixMediaLoader { class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader {
var shouldFail = false var shouldFail = false
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> { override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = withContext(coroutineDispatchers.io){
delay(FAKE_DELAY_IN_MS) if (shouldFail) {
return if (shouldFail) {
Result.failure(RuntimeException()) Result.failure(RuntimeException())
} else { } else {
return Result.success(ByteArray(0)) Result.success(ByteArray(0))
} }
} }
override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> { override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> = withContext(coroutineDispatchers.io){
delay(FAKE_DELAY_IN_MS) if (shouldFail) {
return if (shouldFail) {
Result.failure(RuntimeException()) Result.failure(RuntimeException())
} else { } else {
return Result.success(ByteArray(0)) Result.success(ByteArray(0))
} }
} }
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> { override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> = withContext(coroutineDispatchers.io){
delay(FAKE_DELAY_IN_MS) if (shouldFail) {
return if (shouldFail) {
Result.failure(RuntimeException()) Result.failure(RuntimeException())
} else { } else {
return Result.success(FakeMediaFile("")) Result.success(FakeMediaFile(""))
} }
} }
} }

View file

@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() {
val baseDirectory = File(applicationContext.filesDir, "sessions") val baseDirectory = File(applicationContext.filesDir, "sessions")
RustMatrixAuthenticationService( RustMatrixAuthenticationService(
context = applicationContext,
baseDirectory = baseDirectory, baseDirectory = baseDirectory,
coroutineScope = Singleton.appScope, coroutineScope = Singleton.appScope,
coroutineDispatchers = Singleton.coroutineDispatchers, coroutineDispatchers = Singleton.coroutineDispatchers,

View file

@ -28,12 +28,6 @@ android {
dependencies { dependencies {
implementation(libs.test.junit) implementation(libs.test.junit)
implementation(libs.test.mockk)
implementation(libs.test.truth)
implementation(libs.test.turbine)
implementation(libs.coroutines.test) implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.test)
implementation(projects.services.appnavstate.test)
implementation(projects.services.appnavstate.test)
implementation(projects.libraries.core) implementation(projects.libraries.core)
} }