mirror of
https://github.com/kiwix/kiwix-android.git
synced 2025-08-03 10:46:53 -04:00
Introduced the turbine library for efficiently testing the coroutine flows.
* Removed the `TestObserver` since it was sometimes stucks on the execution. * Refactored the test cases according to turbine library.
This commit is contained in:
parent
2f9439391a
commit
4af14d8c98
@ -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")
|
||||
|
@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Kiwix Android
|
||||
* Copyright (c) 2025 Kiwix <android.kiwix.org>
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
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 <T> Flow<T>.test(scope: TestScope, itemCountsToEmitInFlow: Int = 1): TestObserver<T> =
|
||||
TestObserver(scope, this, itemCountsToEmitInFlow).also { it.startCollecting() }
|
||||
|
||||
class TestObserver<T>(
|
||||
private val scope: TestScope,
|
||||
private val flow: Flow<T>,
|
||||
private val itemCountsToEmitInFlow: Int
|
||||
) {
|
||||
private val values = mutableListOf<T>()
|
||||
private val completionChannel = Channel<Unit>()
|
||||
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<T> {
|
||||
if (shouldAwaitCompletion) {
|
||||
awaitCompletion()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
private suspend fun awaitCompletion() {
|
||||
repeat(itemCountsToEmitInFlow) {
|
||||
completionChannel.receive()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assertValues(listValues: MutableList<T>): TestObserver<T> {
|
||||
awaitCompletion()
|
||||
assertThat(listValues).containsExactlyElementsOf(values)
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun containsExactlyInAnyOrder(
|
||||
listValues: MutableList<T>,
|
||||
vararg values: T
|
||||
): TestObserver<T> {
|
||||
awaitCompletion()
|
||||
assertThat(listValues).containsExactlyInAnyOrder(*values)
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun assertLastValue(value: T): TestObserver<T> {
|
||||
awaitCompletion()
|
||||
assertThat(values.last()).isEqualTo(value)
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun finish() {
|
||||
job?.cancelAndJoin()
|
||||
}
|
||||
|
||||
suspend fun assertLastValue(value: (T) -> Boolean): TestObserver<T> {
|
||||
awaitCompletion()
|
||||
assertThat(values.last()).satisfies({ value(it) })
|
||||
return this
|
||||
}
|
||||
}
|
@ -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 <T> TestScope.testFlow(
|
||||
flow: Flow<T>,
|
||||
triggerAction: suspend () -> Unit,
|
||||
assert: suspend TurbineTestContext<T>.() -> Unit
|
||||
) {
|
||||
val job = launch {
|
||||
flow.test {
|
||||
triggerAction()
|
||||
assert()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
job.join()
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -63,4 +63,5 @@ dependencies {
|
||||
testImplementation(Libs.kotlinx_coroutines_test)
|
||||
implementation(Libs.kotlinx_coroutines_android)
|
||||
implementation(Libs.zxing)
|
||||
testImplementation(Libs.TURBINE_FLOW_TEST)
|
||||
}
|
||||
|
@ -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<BookOnDisk>()) }
|
||||
)
|
||||
}
|
||||
|
||||
@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>(
|
||||
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)
|
||||
|
@ -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<File>()) }
|
||||
)
|
||||
}
|
||||
|
||||
@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<File>()) }
|
||||
)
|
||||
}
|
||||
|
||||
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 <T> TestScope.testFlow(
|
||||
flow: Flow<T>,
|
||||
triggerAction: suspend () -> Unit,
|
||||
assert: suspend TurbineTestContext<T>.() -> Unit
|
||||
) {
|
||||
val job = launch {
|
||||
flow.test {
|
||||
triggerAction()
|
||||
assert()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
job.join()
|
||||
}
|
||||
|
@ -62,6 +62,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
testImplementation(Libs.kotlinx_coroutines_test)
|
||||
testImplementation(Libs.TURBINE_FLOW_TEST)
|
||||
}
|
||||
|
||||
fun ProductFlavor.createDownloadTask(file: File): TaskProvider<Task> {
|
||||
|
@ -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 <T> Flow<T>.test(scope: TestScope, itemCountsToEmitInFlow: Int = 1): TestObserver<T> =
|
||||
TestObserver(scope, this, itemCountsToEmitInFlow).also { it.startCollecting() }
|
||||
|
||||
class TestObserver<T>(
|
||||
private val scope: TestScope,
|
||||
private val flow: Flow<T>,
|
||||
private val itemCountsToEmitInFlow: Int
|
||||
) {
|
||||
private val values = mutableListOf<T>()
|
||||
private val completionChannel = Channel<Unit>()
|
||||
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<T>): TestObserver<T> {
|
||||
awaitCompletion()
|
||||
assertThat(listValues).containsExactlyElementsOf(values)
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun assertLastValues(listValues: MutableList<T>): TestObserver<T> {
|
||||
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 <T> TestScope.testFlow(
|
||||
flow: Flow<T>,
|
||||
triggerAction: suspend () -> Unit,
|
||||
assert: suspend TurbineTestContext<T>.() -> Unit
|
||||
) {
|
||||
val job = launch {
|
||||
flow.test {
|
||||
triggerAction()
|
||||
assert()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
job.join()
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user