diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2226a86b1..b6833f1c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,7 @@ androidComponents { dependencies { androidTestImplementation(Libs.leakcanary_android_instrumentation) testImplementation(Libs.kotlinx_coroutines_test) + testImplementation(Libs.TURBINE_FLOW_TEST) } tasks.register("generateVersionCodeAndName") { val file = File("VERSION_INFO") diff --git a/app/src/test/java/org/kiwix/kiwixmobile/TestObserver.kt b/app/src/test/java/org/kiwix/kiwixmobile/TestObserver.kt deleted file mode 100644 index 03298bece..000000000 --- a/app/src/test/java/org/kiwix/kiwixmobile/TestObserver.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2025 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package org.kiwix.kiwixmobile - -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import org.assertj.core.api.Assertions.assertThat - -fun Flow.test(scope: TestScope, itemCountsToEmitInFlow: Int = 1): TestObserver = - TestObserver(scope, this, itemCountsToEmitInFlow).also { it.startCollecting() } - -class TestObserver( - private val scope: TestScope, - private val flow: Flow, - private val itemCountsToEmitInFlow: Int -) { - private val values = mutableListOf() - private val completionChannel = Channel() - private var job: Job? = null - - fun startCollecting() { - job = scope.launch { - flow.collect { - values.add(it) - completionChannel.send(Unit) - } - } - } - - /** - * Returns the list of values collected from the flow. - * - * If [shouldAwaitCompletion] is true, this method will suspend until the flow - * signals completion, ensuring all values have been collected before returning. - * - * @param shouldAwaitCompletion Whether to wait for the flow to finish collecting before returning the results. - * @return A mutable list of values emitted by the flow. - */ - suspend fun getValues(shouldAwaitCompletion: Boolean = true): MutableList { - if (shouldAwaitCompletion) { - awaitCompletion() - } - return values - } - - private suspend fun awaitCompletion() { - repeat(itemCountsToEmitInFlow) { - completionChannel.receive() - } - } - - suspend fun assertValues(listValues: MutableList): TestObserver { - awaitCompletion() - assertThat(listValues).containsExactlyElementsOf(values) - return this - } - - suspend fun containsExactlyInAnyOrder( - listValues: MutableList, - vararg values: T - ): TestObserver { - awaitCompletion() - assertThat(listValues).containsExactlyInAnyOrder(*values) - return this - } - - suspend fun assertLastValue(value: T): TestObserver { - awaitCompletion() - assertThat(values.last()).isEqualTo(value) - return this - } - - suspend fun finish() { - job?.cancelAndJoin() - } - - suspend fun assertLastValue(value: (T) -> Boolean): TestObserver { - awaitCompletion() - assertThat(values.last()).satisfies({ value(it) }) - return this - } -} diff --git a/app/src/test/java/org/kiwix/kiwixmobile/zimManager/ZimManageViewModelTest.kt b/app/src/test/java/org/kiwix/kiwixmobile/zimManager/ZimManageViewModelTest.kt index 52d923d19..370faf934 100644 --- a/app/src/test/java/org/kiwix/kiwixmobile/zimManager/ZimManageViewModelTest.kt +++ b/app/src/test/java/org/kiwix/kiwixmobile/zimManager/ZimManageViewModelTest.kt @@ -23,6 +23,8 @@ import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.os.Build +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test import com.jraska.livedata.test import io.mockk.clearAllMocks import io.mockk.every @@ -34,9 +36,13 @@ import io.reactivex.processors.PublishProcessor import io.reactivex.schedulers.TestScheduler import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled @@ -65,7 +71,6 @@ import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListIte import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode.MULTI import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.SelectionMode.NORMAL -import org.kiwix.kiwixmobile.test import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile @@ -213,7 +218,7 @@ class ZimManageViewModelTest { inner class Books { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `emissions from dat source are observed`() { + fun `emissions from data source are observed`() { val expectedList = listOf(bookOnDisk()) booksOnDiskListItems.onNext(expectedList) testScheduler.triggerActions() @@ -486,10 +491,11 @@ class ZimManageViewModelTest { ), NORMAL ) - viewModel.sideEffects.test(this) - .also { viewModel.fileSelectActions.emit(RequestMultiSelection(bookToSelect)) } - .assertValues(mutableListOf(StartMultiSelection(viewModel.fileSelectActions))) - .finish() + testFlow( + flow = viewModel.sideEffects, + triggerAction = { viewModel.fileSelectActions.emit(RequestMultiSelection(bookToSelect)) }, + assert = { assertThat(awaitItem()).isEqualTo(StartMultiSelection(viewModel.fileSelectActions)) } + ) viewModel.fileSelectListStates.test() .assertValue( FileSelectListState( @@ -504,10 +510,18 @@ class ZimManageViewModelTest { val selectedBook = bookOnDisk().apply { isSelected = true } viewModel.fileSelectListStates.value = FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL) - viewModel.sideEffects.test(this) - .also { viewModel.fileSelectActions.emit(RequestDeleteMultiSelection) } - .assertValues(mutableListOf(DeleteFiles(listOf(selectedBook), alertDialogShower))) - .finish() + testFlow( + flow = viewModel.sideEffects, + triggerAction = { viewModel.fileSelectActions.emit(RequestDeleteMultiSelection) }, + assert = { + assertThat(awaitItem()).isEqualTo( + DeleteFiles( + listOf(selectedBook), + alertDialogShower + ) + ) + } + ) } @Test @@ -515,10 +529,11 @@ class ZimManageViewModelTest { val selectedBook = bookOnDisk().apply { isSelected = true } viewModel.fileSelectListStates.value = FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL) - viewModel.sideEffects.test(this) - .also { viewModel.fileSelectActions.emit(RequestShareMultiSelection) } - .assertValues(mutableListOf(ShareFiles(listOf(selectedBook)))) - .finish() + testFlow( + flow = viewModel.sideEffects, + triggerAction = { viewModel.fileSelectActions.emit(RequestShareMultiSelection) }, + assert = { assertThat(awaitItem()).isEqualTo(ShareFiles(listOf(selectedBook))) } + ) } @Test @@ -526,10 +541,11 @@ class ZimManageViewModelTest { val selectedBook = bookOnDisk().apply { isSelected = true } viewModel.fileSelectListStates.value = FileSelectListState(listOf(selectedBook, bookOnDisk()), NORMAL) - viewModel.sideEffects.test(this) - .also { viewModel.fileSelectActions.emit(MultiModeFinished) } - .assertValues(mutableListOf(None)) - .finish() + testFlow( + flow = viewModel.sideEffects, + triggerAction = { viewModel.fileSelectActions.emit(MultiModeFinished) }, + assert = { assertThat(awaitItem()).isEqualTo(None) } + ) viewModel.fileSelectListStates.test().assertValue( FileSelectListState( listOf( @@ -545,10 +561,11 @@ class ZimManageViewModelTest { val selectedBook = bookOnDisk(0L).apply { isSelected = true } viewModel.fileSelectListStates.value = FileSelectListState(listOf(selectedBook, bookOnDisk(1L)), NORMAL) - viewModel.sideEffects.test(this) - .also { viewModel.fileSelectActions.emit(RequestSelect(selectedBook)) } - .assertValues(mutableListOf(None)) - .finish() + testFlow( + flow = viewModel.sideEffects, + triggerAction = { viewModel.fileSelectActions.emit(RequestSelect(selectedBook)) }, + assert = { assertThat(awaitItem()).isEqualTo(None) } + ) viewModel.fileSelectListStates.test().assertValue( FileSelectListState( listOf( @@ -561,10 +578,26 @@ class ZimManageViewModelTest { @Test fun `RestartActionMode offers StartMultiSelection`() = runTest { - viewModel.sideEffects.test(this) - .also { viewModel.fileSelectActions.emit(RestartActionMode) } - .assertValues(mutableListOf(StartMultiSelection(viewModel.fileSelectActions))) - .finish() + testFlow( + flow = viewModel.sideEffects, + triggerAction = { viewModel.fileSelectActions.emit(RestartActionMode) }, + assert = { assertThat(awaitItem()).isEqualTo(StartMultiSelection(viewModel.fileSelectActions)) } + ) } } } + +suspend fun TestScope.testFlow( + flow: Flow, + triggerAction: suspend () -> Unit, + assert: suspend TurbineTestContext.() -> Unit +) { + val job = launch { + flow.test { + triggerAction() + assert() + cancelAndIgnoreRemainingEvents() + } + } + job.join() +} diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index a476be4b9..7b660fe67 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -29,6 +29,11 @@ object Libs { const val kotlinx_coroutines_test: String = "org.jetbrains.kotlinx:kotlinx-coroutines-test:" + Versions.org_jetbrains_kotlinx_kotlinx_coroutines + /** + * https://github.com/cashapp/turbine + */ + const val TURBINE_FLOW_TEST: String = "app.cash.turbine:turbine:" + Versions.TURBINE_FLOW_TEST + /** * https://developer.android.com/testing */ diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index d801e32c8..d3a0becc2 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -119,6 +119,8 @@ object Versions { const val COMPOSE_VERSION = "1.7.8" const val COMPOSE_MATERIAL3 = "1.3.1" + + const val TURBINE_FLOW_TEST = "1.2.0" } /** diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8551b6e55..dfac6a2c7 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -63,4 +63,5 @@ dependencies { testImplementation(Libs.kotlinx_coroutines_test) implementation(Libs.kotlinx_coroutines_android) implementation(Libs.zxing) + testImplementation(Libs.TURBINE_FLOW_TEST) } diff --git a/core/src/test/java/org/kiwix/kiwixmobile/core/StorageObserverTest.kt b/core/src/test/java/org/kiwix/kiwixmobile/core/StorageObserverTest.kt index c14bbcdc0..6560c8cc9 100644 --- a/core/src/test/java/org/kiwix/kiwixmobile/core/StorageObserverTest.kt +++ b/core/src/test/java/org/kiwix/kiwixmobile/core/StorageObserverTest.kt @@ -26,8 +26,8 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -37,10 +37,11 @@ import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Factory import org.kiwix.kiwixmobile.core.reader.ZimReaderSource -import org.kiwix.kiwixmobile.core.search.viewmodel.test import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.files.FileSearch import org.kiwix.kiwixmobile.core.utils.files.ScanningProgressListener +import org.kiwix.kiwixmobile.core.utils.files.testFlow +import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.BooksOnDiskListItem.BookOnDisk import org.kiwix.sharedFunctions.book import org.kiwix.sharedFunctions.bookOnDisk import org.kiwix.sharedFunctions.resetSchedulers @@ -86,7 +87,11 @@ class StorageObserverTest { @Test fun `books from disk are filtered by current downloads`() = runTest { withFiltering() - booksOnFileSystem(this).assertValues(mutableListOf(mutableListOf())).finish() + testFlow( + flow = booksOnFileSystem(), + triggerAction = {}, + assert = { assertThat(awaitItem()).isEqualTo(listOf()) } + ) } @OptIn(ExperimentalCoroutinesApi::class) @@ -100,23 +105,25 @@ class StorageObserverTest { withNoFiltering() every { zimFileReader.toBook() } returns expectedBook every { zimFileReader.zimReaderSource } returns zimReaderSource - val testObserver = booksOnFileSystem(this) - testObserver.assertValues( - mutableListOf( - mutableListOf( - bookOnDisk( - book = expectedBook, - zimReaderSource = zimReaderSource + testFlow( + flow = booksOnFileSystem(), + triggerAction = {}, + assert = { + assertThat(awaitItem()).isEqualTo( + listOf( + bookOnDisk( + book = expectedBook, + zimReaderSource = zimReaderSource + ) ) ) - ) - ).finish() + } + ) verify { zimFileReader.dispose() } } - private fun booksOnFileSystem(testScope: TestScope) = + private fun booksOnFileSystem() = storageObserver.getBooksOnFileSystem(scanningProgressListener) - .test(testScope) .also { downloads.value = listOf(downloadModel) files.value = listOf(file) diff --git a/core/src/test/java/org/kiwix/kiwixmobile/core/utils/files/FileSearchTest.kt b/core/src/test/java/org/kiwix/kiwixmobile/core/utils/files/FileSearchTest.kt index 4def7763a..46c2323a6 100644 --- a/core/src/test/java/org/kiwix/kiwixmobile/core/utils/files/FileSearchTest.kt +++ b/core/src/test/java/org/kiwix/kiwixmobile/core/utils/files/FileSearchTest.kt @@ -23,6 +23,8 @@ import android.content.Context import android.database.Cursor import android.os.Environment import android.provider.MediaStore.MediaColumns +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test import eu.mhutti1.utils.storage.StorageDevice import eu.mhutti1.utils.storage.StorageDeviceUtils import io.mockk.clearMocks @@ -30,12 +32,15 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.kiwix.kiwixmobile.core.search.viewmodel.test import org.kiwix.sharedFunctions.resetSchedulers import org.kiwix.sharedFunctions.setScheduler import java.io.File @@ -81,10 +86,11 @@ class FileSearchTest { @Test fun `scan of directory that doesn't exist returns nothing`() = runTest { every { contentResolver.query(any(), any(), any(), any(), any()) } returns null - fileSearch.scan(scanningProgressListener) - .test(this) - .assertValues(mutableListOf(mutableListOf())) - .finish() + testFlow( + flow = fileSearch.scan(scanningProgressListener), + triggerAction = {}, + assert = { assertThat(awaitItem()).isEqualTo(emptyList()) } + ) } @Test @@ -94,13 +100,11 @@ class FileSearchTest { File.createTempFile("willNotFind", ".txt") every { contentResolver.query(any(), any(), any(), any(), any()) } returns null every { storageDevice.name } returns zimFile.parent - val testObserver = fileSearch.scan(scanningProgressListener) - .test(this) - val observedValues = testObserver.getValues().first() - testObserver.containsExactlyInAnyOrder( - mutableListOf(observedValues), - listOf(zimFile, zimaaFile) - ).finish() + testFlow( + flow = fileSearch.scan(scanningProgressListener), + triggerAction = {}, + assert = { assertThat(awaitItem()).containsExactlyInAnyOrder(zimFile, zimaaFile) } + ) } @Test @@ -116,13 +120,11 @@ class FileSearchTest { ) every { contentResolver.query(any(), any(), any(), any(), any()) } returns null every { storageDevice.name } returns zimFile.parentFile.parent - val testObserver = fileSearch.scan(scanningProgressListener) - .test(this) - val observedValue = testObserver.getValues()[0] - testObserver.containsExactlyInAnyOrder( - mutableListOf(observedValue), - listOf(zimFile) - ).finish() + testFlow( + flow = fileSearch.scan(scanningProgressListener), + triggerAction = {}, + assert = { assertThat(awaitItem()[0]).isEqualTo(zimFile) } + ) } } @@ -132,10 +134,11 @@ class FileSearchTest { fun `scan media store, if files are readable they are returned`() = runTest { val fileToFind = File.createTempFile("fileToFind", ".zim") expectFromMediaStore(fileToFind) - fileSearch.scan(scanningProgressListener) - .test(this) - .assertValues(mutableListOf(listOf(fileToFind))) - .finish() + testFlow( + flow = fileSearch.scan(scanningProgressListener), + triggerAction = {}, + assert = { assertThat(awaitItem()).isEqualTo(listOf(fileToFind)) } + ) } @Test @@ -143,10 +146,11 @@ class FileSearchTest { val unreadableFile = File.createTempFile("fileToFind", ".zim") expectFromMediaStore(unreadableFile) unreadableFile.delete() - fileSearch.scan(scanningProgressListener) - .test(this) - .assertValues(mutableListOf(mutableListOf())) - .finish() + testFlow( + flow = fileSearch.scan(scanningProgressListener), + triggerAction = {}, + assert = { assertThat(awaitItem()).isEqualTo(listOf()) } + ) } private fun expectFromMediaStore(fileToFind: File) { @@ -168,7 +172,26 @@ class FileSearchTest { } private fun deleteTempDirectory() { - File.createTempFile("temp", ".txt") - .parentFile.deleteRecursively() + try { + File.createTempFile("temp", ".txt") + .parentFile.deleteRecursively() + } catch (ignore: Exception) { + ignore.printStackTrace() + } } } + +suspend fun TestScope.testFlow( + flow: Flow, + triggerAction: suspend () -> Unit, + assert: suspend TurbineTestContext.() -> Unit +) { + val job = launch { + flow.test { + triggerAction() + assert() + cancelAndIgnoreRemainingEvents() + } + } + job.join() +} diff --git a/custom/build.gradle.kts b/custom/build.gradle.kts index 73baf4f7b..e7ae81509 100644 --- a/custom/build.gradle.kts +++ b/custom/build.gradle.kts @@ -62,6 +62,7 @@ android { dependencies { testImplementation(Libs.kotlinx_coroutines_test) + testImplementation(Libs.TURBINE_FLOW_TEST) } fun ProductFlavor.createDownloadTask(file: File): TaskProvider { diff --git a/custom/src/test/java/org/kiwix/kiwixmobile/custom/download/CustomDownloadViewModelTest.kt b/custom/src/test/java/org/kiwix/kiwixmobile/custom/download/CustomDownloadViewModelTest.kt index 8c91d0bd5..2fba64409 100644 --- a/custom/src/test/java/org/kiwix/kiwixmobile/custom/download/CustomDownloadViewModelTest.kt +++ b/custom/src/test/java/org/kiwix/kiwixmobile/custom/download/CustomDownloadViewModelTest.kt @@ -18,23 +18,19 @@ package org.kiwix.kiwixmobile.custom.download +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test import com.tonyodev.fetch2.Error.NONE import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -79,16 +75,20 @@ internal class CustomDownloadViewModelTest { @Test internal fun `effects emits SetPreferred on Subscribe`() = runTest { - customDownloadViewModel.effects.test(this) - .assertValues(mutableListOf(setPreferredStorageWithMostSpace)) - .finish() + testFlow( + flow = customDownloadViewModel.effects, + triggerAction = {}, + assert = { assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace) } + ) } @Test internal fun `initial State is DownloadRequired`() = runTest { - customDownloadViewModel.state.test(this) - .assertValues(mutableListOf(State.DownloadRequired)) - .finish() + testFlow( + flow = customDownloadViewModel.state, + triggerAction = {}, + assert = { assertThat(awaitItem()).isEqualTo(State.DownloadRequired) } + ) } @Nested @@ -97,26 +97,26 @@ internal class CustomDownloadViewModelTest { internal fun `Emission with data moves state from Required to InProgress`() = runTest { assertStateTransition( this, - 1, DownloadRequired, DatabaseEmission(listOf(downloadItem())), - State.DownloadInProgress(listOf(downloadItem())) + State.DownloadInProgress(listOf(downloadItem())), + 2 ) } @Test internal fun `Emission without data moves state from Required to Required`() = runTest { - assertStateTransition(this, 1, DownloadRequired, DatabaseEmission(listOf()), DownloadRequired) + assertStateTransition(this, DownloadRequired, DatabaseEmission(listOf()), DownloadRequired) } - @Disabled + @Test internal fun `Emission with data moves state from Failed to InProgress`() = runTest { assertStateTransition( this, - 1, DownloadFailed(DownloadState.Pending), DatabaseEmission(listOf(downloadItem())), - State.DownloadInProgress(listOf(downloadItem())) + State.DownloadInProgress(listOf(downloadItem())), + 2 ) } @@ -124,7 +124,6 @@ internal class CustomDownloadViewModelTest { internal fun `Emission without data moves state from Failed to Failed`() = runTest { assertStateTransition( this, - 1, DownloadFailed(DownloadState.Pending), DatabaseEmission(listOf()), DownloadFailed(DownloadState.Pending) @@ -135,10 +134,10 @@ internal class CustomDownloadViewModelTest { internal fun `Emission with data+failure moves state from InProgress to Failed`() = runTest { assertStateTransition( this, - 1, DownloadInProgress(listOf()), DatabaseEmission(listOf(downloadItem(state = Failed(NONE, null)))), - DownloadFailed(Failed(NONE, null)) + DownloadFailed(Failed(NONE, null)), + 2 ) } @@ -146,117 +145,98 @@ internal class CustomDownloadViewModelTest { internal fun `Emission with data moves state from InProgress to InProgress`() = runTest { assertStateTransition( this, - 1, DownloadInProgress(listOf(downloadItem(downloadId = 1L))), DatabaseEmission(listOf(downloadItem(downloadId = 2L))), - DownloadInProgress(listOf(downloadItem(downloadId = 2L))) + DownloadInProgress(listOf(downloadItem(downloadId = 2L))), + 2 ) } - @Disabled("TODO fix in upcoming issue when properly migrated to coroutines") + @Test internal fun `Emission without data moves state from InProgress to Complete`() = runTest { - val sideEffects = customDownloadViewModel.effects.test(this) - assertStateTransition( - this, - 1, - DownloadInProgress(listOf()), - DatabaseEmission(listOf()), - DownloadComplete + testFlow( + flow = customDownloadViewModel.effects, + triggerAction = { + assertStateTransition( + this, + DownloadInProgress(listOf()), + DatabaseEmission(listOf()), + DownloadComplete, + 2 + ) + }, + assert = { + assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace) + assertThat(awaitItem()).isEqualTo(navigateToCustomReader) + } ) - sideEffects.assertValues( - mutableListOf( - setPreferredStorageWithMostSpace, - navigateToCustomReader - ) - ).finish() } @Test internal fun `Any emission does not change state from Complete`() = runTest { assertStateTransition( this, - 1, DownloadComplete, DatabaseEmission(listOf(downloadItem())), DownloadComplete ) } - @OptIn(ExperimentalCoroutinesApi::class) - private fun assertStateTransition( + private suspend fun assertStateTransition( testScope: TestScope, - flowCount: Int, initialState: State, action: DatabaseEmission, - endState: State + endState: State, + awaitItemCount: Int = 1 ) { customDownloadViewModel.getStateForTesting().value = initialState - testScope.launch { - customDownloadViewModel.actions.emit(action) - testScope.advanceUntilIdle() - customDownloadViewModel.state.test(testScope, flowCount) - .assertLastValues(mutableListOf(endState)).finish() - } + testScope.testFlow( + flow = customDownloadViewModel.state, + triggerAction = { customDownloadViewModel.actions.emit(action) }, + assert = { + val items = (1..awaitItemCount).map { awaitItem() } + assertThat(items.last()).isEqualTo(endState) + } + ) } } - @Disabled("TODO fix in upcoming issue when properly migrated to coroutines") + @Test internal fun `clicking Retry triggers DownloadCustom`() = runTest { - val sideEffects = customDownloadViewModel.effects.test(this) - customDownloadViewModel.actions.emit(ClickedRetry) - sideEffects.assertValues(mutableListOf(setPreferredStorageWithMostSpace, downloadCustom)) - .finish() - } - - @Disabled("TODO fix in upcoming issue when properly migrated to coroutines") - internal fun `clicking Download triggers DownloadCustom`() = runTest { - val sideEffects = customDownloadViewModel.effects.test(this) - customDownloadViewModel.actions.emit(ClickedDownload) - sideEffects.assertValues(mutableListOf(setPreferredStorageWithMostSpace, downloadCustom)) - .finish() - } -} - -fun Flow.test(scope: TestScope, itemCountsToEmitInFlow: Int = 1): TestObserver = - TestObserver(scope, this, itemCountsToEmitInFlow).also { it.startCollecting() } - -class TestObserver( - private val scope: TestScope, - private val flow: Flow, - private val itemCountsToEmitInFlow: Int -) { - private val values = mutableListOf() - private val completionChannel = Channel() - private var job: Job? = null - - fun startCollecting() { - job = scope.launch { - flow.collect { - values.add(it) - completionChannel.send(Unit) + testFlow( + flow = customDownloadViewModel.effects, + triggerAction = { customDownloadViewModel.actions.emit(ClickedRetry) }, + assert = { + assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace) + assertThat(awaitItem()).isEqualTo(downloadCustom) } - } + ) } - private suspend fun awaitCompletion() { - repeat(itemCountsToEmitInFlow) { - completionChannel.receive() - } - } - - suspend fun assertValues(listValues: MutableList): TestObserver { - awaitCompletion() - assertThat(listValues).containsExactlyElementsOf(values) - return this - } - - suspend fun assertLastValues(listValues: MutableList): TestObserver { - awaitCompletion() - assertThat(listValues).containsExactlyElementsOf(mutableListOf(values.last())) - return this - } - - suspend fun finish() { - job?.cancelAndJoin() + @Test + internal fun `clicking Download triggers DownloadCustom`() = runTest { + testFlow( + flow = customDownloadViewModel.effects, + triggerAction = { customDownloadViewModel.actions.emit(ClickedDownload) }, + assert = { + assertThat(awaitItem()).isEqualTo(setPreferredStorageWithMostSpace) + assertThat(awaitItem()).isEqualTo(downloadCustom) + } + ) } } + +suspend fun TestScope.testFlow( + flow: Flow, + triggerAction: suspend () -> Unit, + assert: suspend TurbineTestContext.() -> Unit +) { + val job = launch { + flow.test { + triggerAction() + assert() + cancelAndIgnoreRemainingEvents() + } + } + job.join() +}