Refactored the remaining test cases of ZimManageViewModel to follow the new approach. Additionally, added new unit test cases to cover new functionalities such as pagination, fetching new content when changing language, searching, etc.

* Improved some unit test cases that sometimes failed on CI due to coroutine IO threading issues.
This commit is contained in:
MohitMaliFtechiz 2025-07-11 04:23:08 +05:30
parent 136052e0bb
commit badddc8aab
7 changed files with 165 additions and 243 deletions

View File

@ -60,14 +60,6 @@ class OnlineLibraryManager @Inject constructor(
it.printStackTrace()
}.getOrNull()
suspend fun getOnlineBooksLanguage(): List<String> {
return runCatching {
library.booksLanguages.distinct()
}.onFailure {
it.printStackTrace()
}.getOrDefault(emptyList())
}
/**
* Builds the URL for fetching the OPDS library entries with pagination and optional filters.
*

View File

@ -163,6 +163,8 @@ class ZimManageViewModel @Inject constructor(
val books: List<LibkiwixBook>
)
@Suppress("InjectDispatcher")
private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private var isUnitTestCase: Boolean = false
val sideEffects: MutableSharedFlow<SideEffect<*>> = MutableSharedFlow()
private val _libraryItems = MutableStateFlow<List<LibraryListItem>>(emptyList())
@ -313,11 +315,11 @@ class ZimManageViewModel @Inject constructor(
onCleared()
}
private fun observeCoroutineFlows(dispatcher: CoroutineDispatcher = Dispatchers.IO) {
private fun observeCoroutineFlows() {
val downloads = downloadDao.downloads()
val booksFromDao = books()
coroutineJobs.apply {
add(scanBooksFromStorage(dispatcher))
add(scanBooksFromStorage())
add(updateBookItems())
add(fileSelectActions())
add(updateLibraryItems(booksFromDao, downloads, networkLibrary))
@ -340,7 +342,7 @@ class ZimManageViewModel @Inject constructor(
}
@OptIn(FlowPreview::class)
private fun observeSearch(dispatcher: CoroutineDispatcher = Dispatchers.IO) =
private fun observeSearch() =
requestFiltering
.onEach {
libraryListIsRefreshing.postValue(true)
@ -349,17 +351,17 @@ class ZimManageViewModel @Inject constructor(
)
}
.debounce(500)
.flowOn(dispatcher)
.flowOn(ioDispatcher)
.launchIn(viewModelScope)
private fun observeLanguageChanges(dispatcher: CoroutineDispatcher = Dispatchers.IO) =
private fun observeLanguageChanges() =
sharedPreferenceUtil.onlineContentLanguage
.onEach {
updateOnlineLibraryFilters(
OnlineLibraryRequest(lang = it, page = ZERO, isLoadMoreItem = false)
)
}
.flowOn(dispatcher)
.flowOn(ioDispatcher)
.launchIn(viewModelScope)
fun updateOnlineLibraryFilters(newRequest: OnlineLibraryRequest) {
@ -381,11 +383,11 @@ class ZimManageViewModel @Inject constructor(
}
.launchIn(viewModelScope)
private fun scanBooksFromStorage(dispatcher: CoroutineDispatcher = Dispatchers.IO) =
private fun scanBooksFromStorage() =
checkFileSystemForBooksOnRequest(books())
.catch { it.printStackTrace() }
.onEach { books -> libkiwixBookOnDisk.insert(books) }
.flowOn(dispatcher)
.flowOn(ioDispatcher)
.launchIn(viewModelScope)
private fun fileSelectActions() =
@ -472,14 +474,11 @@ class ZimManageViewModel @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
private fun requestsAndConnectivityChangesToLibraryRequests(
library: MutableStateFlow<List<LibkiwixBook>>,
dispatcher: CoroutineDispatcher = Dispatchers.IO
library: MutableStateFlow<List<LibkiwixBook>>
) = requestDownloadLibrary.onEach { onlineLibraryRequest ->
// Cancel any previous ongoing job
onlineLibraryFetchingJob?.cancel()
// Launch new job
onlineLibraryFetchingJob = viewModelScope.launch(dispatcher) {
onlineLibraryFetchingJob = viewModelScope.launch(ioDispatcher) {
connectivityBroadcastReceiver.networkStates
.filter { it == CONNECTED }
.take(1)
@ -499,15 +498,17 @@ class ZimManageViewModel @Inject constructor(
}
}
.collect { result ->
library.value = if (result.onlineLibraryRequest.isLoadMoreItem) {
library.value + result.books
} else {
result.books
}
library.emit(
if (result.onlineLibraryRequest.isLoadMoreItem) {
library.value + result.books
} else {
result.books
}
)
resetDownloadState()
}
}
}.flowOn(dispatcher)
}.flowOn(ioDispatcher)
.launchIn(viewModelScope)
private fun shouldProceedWithDownload(onlineLibraryRequest: OnlineLibraryRequest): Flow<KiwixService> {
@ -606,9 +607,8 @@ class ZimManageViewModel @Inject constructor(
private fun updateLibraryItems(
localBooksFromLibkiwix: Flow<List<Book>>,
downloads: Flow<List<DownloadModel>>,
library: MutableStateFlow<List<LibkiwixBook>>,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) = viewModelScope.launch(dispatcher) {
library: MutableStateFlow<List<LibkiwixBook>>
) = viewModelScope.launch(ioDispatcher) {
combine(
localBooksFromLibkiwix,
downloads,

View File

@ -50,6 +50,7 @@ import org.kiwix.kiwixmobile.language.viewmodel.Action.Select
import org.kiwix.kiwixmobile.language.viewmodel.Action.UpdateLanguages
import org.kiwix.kiwixmobile.language.viewmodel.State.Content
import org.kiwix.kiwixmobile.language.viewmodel.State.Loading
import org.kiwix.kiwixmobile.zimManager.TURBINE_TIMEOUT
import org.kiwix.kiwixmobile.zimManager.testFlow
import org.kiwix.sharedFunctions.InstantExecutorExtension
import org.kiwix.sharedFunctions.language
@ -199,7 +200,8 @@ class LanguageViewModelTest {
assert = {
assertThat(awaitItem()).isEqualTo(Loading)
assertThat(awaitItem()).isEqualTo(Content(listOf()))
}
},
TURBINE_TIMEOUT
)
}

View File

@ -45,6 +45,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.HttpUrl
import okhttp3.Response
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
@ -56,13 +58,13 @@ import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao
import org.kiwix.kiwixmobile.core.dao.LibkiwixBookOnDisk
import org.kiwix.kiwixmobile.core.data.DataSource
import org.kiwix.kiwixmobile.core.data.remote.KiwixService
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel
import org.kiwix.kiwixmobile.core.entity.LibkiwixBook
import org.kiwix.kiwixmobile.core.ui.components.ONE
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower
import org.kiwix.kiwixmobile.core.utils.files.ScanningProgressListener
import org.kiwix.kiwixmobile.core.zim_manager.ConnectivityBroadcastReceiver
import org.kiwix.kiwixmobile.core.zim_manager.Language
import org.kiwix.kiwixmobile.core.zim_manager.NetworkState
import org.kiwix.kiwixmobile.core.zim_manager.NetworkState.CONNECTED
import org.kiwix.kiwixmobile.core.zim_manager.NetworkState.NOT_CONNECTED
@ -87,9 +89,9 @@ import org.kiwix.kiwixmobile.zimManager.fileselectView.effects.StartMultiSelecti
import org.kiwix.kiwixmobile.zimManager.libraryView.adapter.LibraryListItem
import org.kiwix.libkiwix.Book
import org.kiwix.sharedFunctions.InstantExecutorExtension
import org.kiwix.sharedFunctions.MOCK_BASE_URL
import org.kiwix.sharedFunctions.bookOnDisk
import org.kiwix.sharedFunctions.downloadModel
import org.kiwix.sharedFunctions.language
import org.kiwix.sharedFunctions.libkiwixBook
import java.util.Locale
import kotlin.time.Duration
@ -118,7 +120,6 @@ class ZimManageViewModelTest {
private val downloads = MutableStateFlow<List<DownloadModel>>(emptyList())
private val booksOnFileSystem = MutableStateFlow<List<Book>>(emptyList())
private val books = MutableStateFlow<List<BookOnDisk>>(emptyList())
private val languages = MutableStateFlow<List<Language>>(emptyList())
private val onlineContentLanguage = MutableStateFlow("")
private val fileSystemStates =
MutableStateFlow<FileSystemState>(FileSystemState.DetectingFileSystem)
@ -159,16 +160,35 @@ class ZimManageViewModelTest {
} returns networkCapabilities
every { networkCapabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { sharedPreferenceUtil.prefWifiOnly } returns true
every { sharedPreferenceUtil.getCachedLanguageList() } returns languages.value
coEvery { onlineLibraryManager.getOnlineBooksLanguage() } returns listOf("eng")
every { sharedPreferenceUtil.onlineContentLanguage } returns onlineContentLanguage
every { sharedPreferenceUtil.selectedOnlineContentLanguage } returns ""
every { onlineLibraryManager.getStartOffset(any(), any()) } returns ONE
every {
onlineLibraryManager.buildLibraryUrl(
any(),
any(),
any(),
any(),
any(),
any()
)
} returns MOCK_BASE_URL
val response = mockk<retrofit2.Response<String>>()
val rawResponse = mockk<Response>()
every { response.raw() } returns rawResponse
val httpsUrl = mockk<HttpUrl>()
every { httpsUrl.host } returns ""
every { httpsUrl.scheme } returns ""
every { rawResponse.networkResponse?.request?.url } returns httpsUrl
coEvery { kiwixService.getLibraryPage(any()) } returns response
every { response.body() } returns ""
downloads.value = emptyList()
booksOnFileSystem.value = emptyList()
books.value = emptyList()
languages.value = emptyList()
fileSystemStates.value = FileSystemState.DetectingFileSystem
booksOnDiskListItems.value = emptyList()
networkStates.value = NOT_CONNECTED
onlineContentLanguage.value = ""
viewModel =
ZimManageViewModel(
downloadRoomDao,
@ -258,136 +278,17 @@ class ZimManageViewModelTest {
@Nested
inner class Languages {
@Test
fun `network no result & empty language db activates the default locale`() = runTest {
val expectedLanguage =
Language(
active = true,
occurencesOfLanguage = 1,
language = "eng",
languageLocalized = "englocal",
languageCode = "ENG",
languageCodeISO2 = "en"
)
expectNetworkDbAndDefault(
listOf(),
listOf(),
expectedLanguage
)
advanceUntilIdle()
// verify { languageRoomDao.insert(listOf(expectedLanguage)) }
}
@Test
fun `network no result & a language db result triggers nothing`() = runTest {
expectNetworkDbAndDefault(
listOf(),
listOf(
Language(
active = true,
occurencesOfLanguage = 1,
language = "eng",
languageLocalized = "englocal",
languageCode = "ENG",
languageCodeISO2 = "en"
)
),
language(isActive = true, occurencesOfLanguage = 1)
)
// verify { languageRoomDao.insert(any()) }
}
@Test
fun `network result & empty language db triggers combined result of default + network`() =
runTest {
val defaultLanguage =
Language(
active = true,
occurencesOfLanguage = 1,
language = "English",
languageLocalized = "English",
languageCode = "eng",
languageCodeISO2 = "eng"
)
expectNetworkDbAndDefault(
listOf(
libkiwixBook(language = "eng"),
libkiwixBook(language = "eng"),
libkiwixBook(language = "fra")
),
listOf(),
defaultLanguage
)
verify {
// languageRoomDao.insert(
// listOf(
// defaultLanguage.copy(occurencesOfLanguage = 2),
// Language(
// active = false,
// occurencesOfLanguage = 1,
// language = "fra",
// languageLocalized = "",
// languageCode = "",
// languageCodeISO2 = ""
// )
// )
// )
}
}
@Test
fun `network result & language db results activates a combined network + db result`() =
runTest {
val dbLanguage =
Language(
active = true,
occurencesOfLanguage = 1,
language = "English",
languageLocalized = "English",
languageCode = "eng",
languageCodeISO2 = "eng"
)
expectNetworkDbAndDefault(
listOf(
libkiwixBook(language = "eng"),
libkiwixBook(language = "eng"),
libkiwixBook(language = "fra")
),
listOf(dbLanguage),
language(isActive = true, occurencesOfLanguage = 1)
)
advanceUntilIdle()
verify(timeout = MOCKK_TIMEOUT_FOR_VERIFICATION) {
// languageRoomDao.insert(
// listOf(
// dbLanguage.copy(occurencesOfLanguage = 2),
// Language(
// active = false,
// occurencesOfLanguage = 1,
// language = "fra",
// languageLocalized = "fra",
// languageCode = "fra",
// languageCodeISO2 = "fra"
// )
// )
// )
}
}
private suspend fun TestScope.expectNetworkDbAndDefault(
networkBooks: List<LibkiwixBook>,
dbBooks: List<Language>,
defaultLanguage: Language
) {
fun `changing language updates the filter and do the network request`() = runTest {
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
// every { defaultLanguageProvider.provide() } returns defaultLanguage
coEvery { onlineLibraryManager.getOnlineBooksLanguage() } returns networkBooks.map { it.language }
viewModel.networkLibrary.emit(networkBooks)
advanceUntilIdle()
languages.value = dbBooks
advanceUntilIdle()
networkStates.value = CONNECTED
advanceUntilIdle()
viewModel.onlineLibraryRequest.test {
skipItems(1)
onlineContentLanguage.emit("eng")
val onlineLibraryRequest = awaitItem()
assertThat(onlineLibraryRequest.lang).isEqualTo("eng")
assertThat(onlineLibraryRequest.page).isEqualTo(ZERO)
assertThat(onlineLibraryRequest.isLoadMoreItem).isEqualTo(false)
}
}
}
@ -399,6 +300,32 @@ class ZimManageViewModelTest {
.assertValue(NOT_CONNECTED)
}
@Test
fun `updateOnlineLibraryFilters updates onlineLibraryRequest`() = runTest {
val newRequest = ZimManageViewModel.OnlineLibraryRequest(
query = "test",
category = "cat",
lang = "en",
page = 2,
isLoadMoreItem = true
)
viewModel.onlineLibraryRequest.test {
skipItems(1)
viewModel.updateOnlineLibraryFilters(newRequest)
assertThat(awaitItem()).isEqualTo(newRequest)
}
}
@Test
fun `search triggers downloading online content`() = runTest {
viewModel.onlineLibraryRequest.test {
skipItems(1)
viewModel.requestFiltering.emit("test")
advanceUntilIdle()
assertThat(awaitItem().query).isEqualTo("test")
}
}
@Test
fun `library update removes from sources and maps to list items`() = runTest {
val book = BookTestWrapper("0")
@ -406,54 +333,31 @@ class ZimManageViewModelTest {
libkiwixBook(id = "0", url = "", language = Locale.ENGLISH.language, nativeBook = book)
val bookDownloading = libkiwixBook(id = "1", url = "")
val bookWithActiveLanguage = libkiwixBook(id = "3", language = "activeLanguage", url = "")
val bookWithInactiveLanguage = libkiwixBook(id = "4", language = "inactiveLanguage", url = "")
testFlow(
flow = viewModel.libraryItems,
triggerAction = {
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
networkStates.value = CONNECTED
downloads.value = listOf(downloadModel(book = bookDownloading))
books.value = listOf(bookOnDisk(book = bookAlreadyOnDisk))
languages.value =
viewModel.libraryItems.test {
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
coEvery {
onlineLibraryManager.parseOPDSStreamAndGetBooks(any(), any())
} returns arrayListOf(bookWithActiveLanguage)
networkStates.value = CONNECTED
downloads.value = listOf(downloadModel(book = bookDownloading))
books.value = listOf(bookOnDisk(book = bookAlreadyOnDisk))
fileSystemStates.value = CanWrite4GbFile
advanceUntilIdle()
val items = awaitItem()
val bookItems = items.filterIsInstance<LibraryListItem.BookItem>()
if (bookItems.size >= 2 && bookItems[0].fileSystemState == CanWrite4GbFile) {
assertThat(items).isEqualTo(
listOf(
language(isActive = true, occurencesOfLanguage = 1, languageCode = "activeLanguage"),
language(isActive = false, occurencesOfLanguage = 1, languageCode = "inactiveLanguage")
LibraryListItem.DividerItem(Long.MAX_VALUE, "Downloading:"),
LibraryListItem.LibraryDownloadItem(downloadModel(book = bookDownloading)),
LibraryListItem.DividerItem(Long.MAX_VALUE - 1, "All languages"),
LibraryListItem.BookItem(bookWithActiveLanguage, CanWrite4GbFile),
)
fileSystemStates.value = CanWrite4GbFile
runBlocking {
viewModel.networkLibrary.emit(
listOf(
bookAlreadyOnDisk,
bookDownloading,
bookWithActiveLanguage,
bookWithInactiveLanguage
)
)
}
advanceUntilIdle()
},
assert = {
while (true) {
val items = awaitItem()
val bookItems = items.filterIsInstance<LibraryListItem.BookItem>()
if (bookItems.size >= 2 && bookItems[0].fileSystemState == CanWrite4GbFile) {
// assertThat(items).isEqualTo(
// listOf(
// LibraryListItem.DividerItem(Long.MAX_VALUE, R.string.downloading),
// LibraryListItem.LibraryDownloadItem(downloadModel(book = bookDownloading)),
// LibraryListItem.DividerItem(Long.MAX_VALUE - 1, R.string.your_languages),
// LibraryListItem.BookItem(bookWithActiveLanguage, CanWrite4GbFile),
// LibraryListItem.DividerItem(Long.MIN_VALUE, R.string.other_languages),
// LibraryListItem.BookItem(bookWithInactiveLanguage, CanWrite4GbFile)
// )
// )
break
}
}
},
timeout = TURBINE_TIMEOUT
)
)
}
}
}
@Test
@ -464,39 +368,60 @@ class ZimManageViewModelTest {
url = "",
size = "${Fat32Checker.FOUR_GIGABYTES_IN_KILOBYTES + 1}"
)
every { application.getString(any()) } returns ""
every { application.getString(any(), any()) } returns ""
testFlow(
viewModel.libraryItems,
triggerAction = {
networkStates.tryEmit(CONNECTED)
downloads.tryEmit(listOf())
books.tryEmit(listOf())
languages.tryEmit(
every { application.getString(any()) } answers { "" }
every { application.getString(any(), any()) } answers { "" }
every { application.getString(any(), *anyVararg()) } answers { "" }
// test libraryItems fetches for all language.
viewModel.libraryItems.test {
coEvery {
onlineLibraryManager.parseOPDSStreamAndGetBooks(any(), any())
} returns arrayListOf(bookOver4Gb)
networkStates.value = CONNECTED
downloads.value = listOf()
books.value = listOf()
onlineContentLanguage.value = ""
fileSystemStates.emit(FileSystemState.DetectingFileSystem)
fileSystemStates.emit(CannotWrite4GbFile)
advanceUntilIdle()
val item = awaitItem()
val bookItem = item.filterIsInstance<LibraryListItem.BookItem>().firstOrNull()
if (bookItem?.fileSystemState == CannotWrite4GbFile) {
assertThat(item).isEqualTo(
listOf(
language(isActive = true, occurencesOfLanguage = 1, languageCode = "activeLanguage")
LibraryListItem.DividerItem(Long.MIN_VALUE, "All languages"),
LibraryListItem.BookItem(bookOver4Gb, CannotWrite4GbFile)
)
)
fileSystemStates.tryEmit(CannotWrite4GbFile)
viewModel.networkLibrary.emit(listOf(bookOver4Gb))
},
assert = {
while (true) {
val item = awaitItem()
val bookItem = item.filterIsInstance<LibraryListItem.BookItem>().firstOrNull()
if (bookItem?.fileSystemState == CannotWrite4GbFile) {
assertThat(item).isEqualTo(
listOf(
// LibraryListItem.DividerItem(Long.MIN_VALUE, R.string.other_languages),
LibraryListItem.BookItem(bookOver4Gb, CannotWrite4GbFile)
)
)
break
}
}
},
timeout = TURBINE_TIMEOUT
)
}
}
// test library items fetches for a particular language
viewModel.libraryItems.test {
coEvery {
onlineLibraryManager.parseOPDSStreamAndGetBooks(any(), any())
} returns arrayListOf(bookOver4Gb)
every { application.getString(any(), any()) } answers { "Selected language: English" }
networkStates.value = CONNECTED
downloads.value = listOf()
books.value = listOf()
onlineContentLanguage.value = "eng"
fileSystemStates.emit(FileSystemState.DetectingFileSystem)
fileSystemStates.emit(CannotWrite4GbFile)
advanceUntilIdle()
val item = awaitItem()
val bookItem = item.filterIsInstance<LibraryListItem.BookItem>().firstOrNull()
if (bookItem?.fileSystemState == CannotWrite4GbFile) {
assertThat(item).isEqualTo(
listOf(
LibraryListItem.DividerItem(Long.MIN_VALUE, "Selected language: English"),
LibraryListItem.BookItem(bookOver4Gb, CannotWrite4GbFile)
)
)
}
}
}
@Nested
@ -620,6 +545,7 @@ suspend fun <T> TestScope.testFlow(
triggerAction()
assert()
cancelAndIgnoreRemainingEvents()
ensureAllEventsConsumed()
}
}
job.join()

View File

@ -58,6 +58,6 @@ interface KiwixService {
companion object {
const val OPDS_LIBRARY_ENDPOINT = "v2/entries"
const val ITEMS_PER_PAGE = 50
const val ITEMS_PER_PAGE = 25
}
}

View File

@ -183,6 +183,7 @@ suspend fun <T> TestScope.testFlow(
triggerAction()
assert()
cancelAndIgnoreRemainingEvents()
ensureAllEventsConsumed()
}
}
job.join()

View File

@ -241,6 +241,7 @@ suspend fun <T> TestScope.testFlow(
triggerAction()
assert()
cancelAndIgnoreRemainingEvents()
ensureAllEventsConsumed()
}
}
job.join()