#2082 Very slow search function - replace RX with Flow

This commit is contained in:
Sean Mac Gillicuddy 2020-09-22 13:08:00 +01:00
parent 222c0627a1
commit 19bd06a460
25 changed files with 548 additions and 441 deletions

View File

@ -6,7 +6,7 @@ buildscript {
dependencies { dependencies {
classpath(Libs.com_android_tools_build_gradle) classpath(Libs.com_android_tools_build_gradle)
classpath(Libs.kotlin_gradle_plugin) classpath(Libs.kotlin_gradle_plugin)
classpath(Libs.navigation_kotlin_safeargs) classpath(Libs.navigation_safe_args_gradle_plugin)
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files

View File

@ -1,3 +1,5 @@
import kotlin.String
/** /**
* Generated by https://github.com/jmfayard/buildSrcVersions * Generated by https://github.com/jmfayard/buildSrcVersions
* *
@ -5,18 +7,18 @@
* `$ ./gradlew buildSrcVersions` * `$ ./gradlew buildSrcVersions`
*/ */
object Libs { object Libs {
/**
* https://github.com/Kotlin/kotlinx.coroutines
*/
const val kotlinx_coroutines_android: String =
"org.jetbrains.kotlinx:kotlinx-coroutines-android:" +
Versions.org_jetbrains_kotlinx_kotlinx_coroutines
/** /**
* https://developer.android.com/guide/navigation * https://github.com/Kotlin/kotlinx.coroutines
*/ */
const val navigation_kotlin_fragment: String = const val kotlinx_coroutines_test: String = "org.jetbrains.kotlinx:kotlinx-coroutines-test:" +
"androidx.navigation:navigation-fragment-ktx:${Versions.navigation}" Versions.org_jetbrains_kotlinx_kotlinx_coroutines
const val navigation_kotlin_ui: String =
"androidx.navigation:navigation-ui-ktx:${Versions.navigation}"
const val navigation_kotlin_testing: String =
"androidx.navigation:navigation-testing:${Versions.navigation}"
const val navigation_kotlin_safeargs: String =
"androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.navigation}"
/** /**
* https://developer.android.com/testing * https://developer.android.com/testing
@ -106,6 +108,30 @@ object Libs {
const val kotlin_stdlib_jdk7: String = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:" + const val kotlin_stdlib_jdk7: String = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:" +
Versions.org_jetbrains_kotlin Versions.org_jetbrains_kotlin
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_fragment_ktx: String = "androidx.navigation:navigation-fragment-ktx:" +
Versions.androidx_navigation
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_safe_args_gradle_plugin: String =
"androidx.navigation:navigation-safe-args-gradle-plugin:" + Versions.androidx_navigation
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_testing: String = "androidx.navigation:navigation-testing:" +
Versions.androidx_navigation
/**
* https://developer.android.com/topic/libraries/architecture/index.html
*/
const val navigation_ui_ktx: String = "androidx.navigation:navigation-ui-ktx:" +
Versions.androidx_navigation
/** /**
* https://github.com/google/dagger * https://github.com/google/dagger
*/ */
@ -260,9 +286,6 @@ object Libs {
"com.github.triplet.play:com.github.triplet.play.gradle.plugin:" + "com.github.triplet.play:com.github.triplet.play.gradle.plugin:" +
Versions.com_github_triplet_play_gradle_plugin Versions.com_github_triplet_play_gradle_plugin
const val multidex_instrumentation: String = "androidx.multidex:multidex-instrumentation:" +
Versions.multidex_instrumentation
/** /**
* http://jcp.org/en/jsr/detail?id=250 * http://jcp.org/en/jsr/detail?id=250
*/ */

View File

@ -1,3 +1,4 @@
import kotlin.String
import org.gradle.plugin.use.PluginDependenciesSpec import org.gradle.plugin.use.PluginDependenciesSpec
import org.gradle.plugin.use.PluginDependencySpec import org.gradle.plugin.use.PluginDependencySpec
@ -11,23 +12,27 @@ import org.gradle.plugin.use.PluginDependencySpec
* YOU are responsible for updating manually the dependency version. * YOU are responsible for updating manually the dependency version.
*/ */
object Versions { object Versions {
const val androidx_test_espresso: String = "3.2.0" const val org_jetbrains_kotlinx_kotlinx_coroutines: String = "1.3.9"
const val androidx_test_espresso: String = "3.2.0" // available: "3.3.0"
const val com_squareup_retrofit2: String = "2.5.0" // available: "2.9.0" const val com_squareup_retrofit2: String = "2.5.0" // available: "2.9.0"
const val com_squareup_okhttp3: String = "3.6.0" // available: "4.8.0" const val com_squareup_okhttp3: String = "3.6.0" // available: "4.9.0"
const val org_jetbrains_kotlin: String = "1.3.72" const val org_jetbrains_kotlin: String = "1.3.72" // available: "1.4.10"
const val com_google_dagger: String = "2.28.3" const val androidx_navigation: String = "2.3.0"
const val com_google_dagger: String = "2.28.3" // available: "2.29.1"
const val com_yahoo_squidb: String = "2.0.0" // available: "3.2.3" const val com_yahoo_squidb: String = "2.0.0" // available: "3.2.3"
const val com_jakewharton: String = "10.2.2" const val com_jakewharton: String = "10.2.2" // available: "10.2.3"
const val androidx_test: String = "1.2.0" const val androidx_test: String = "1.2.0" // available: "1.3.0"
const val io_objectbox: String = "2.7.0" const val io_objectbox: String = "2.7.0" // available: "2.7.1"
const val org_jacoco: String = "0.7.9" const val org_jacoco: String = "0.7.9"
@ -39,9 +44,7 @@ object Versions {
const val de_fayard_buildsrcversions_gradle_plugin: String = "0.7.0" const val de_fayard_buildsrcversions_gradle_plugin: String = "0.7.0"
const val com_github_triplet_play_gradle_plugin: String = "2.8.0" const val com_github_triplet_play_gradle_plugin: String = "2.8.0" // available: "3.0.0"
const val multidex_instrumentation: String = "2.0.0"
const val javax_annotation_api: String = "1.3.2" const val javax_annotation_api: String = "1.3.2"
@ -49,17 +52,17 @@ object Versions {
const val leakcanary_android: String = "2.4" const val leakcanary_android: String = "2.4"
const val constraintlayout: String = "1.1.3" const val constraintlayout: String = "1.1.3" // available: "2.0.1"
const val collection_ktx: String = "1.1.0" const val collection_ktx: String = "1.1.0"
const val preference_ktx: String = "1.1.1" const val preference_ktx: String = "1.1.1"
const val junit_jupiter: String = "5.6.2" const val junit_jupiter: String = "5.6.2" // available: "5.7.0"
const val xfetch2okhttp: String = "3.1.4" const val xfetch2okhttp: String = "3.1.4" // available: "3.1.5"
const val assertj_core: String = "3.16.1" const val assertj_core: String = "3.16.1" // available: "3.17.2"
const val core_testing: String = "2.1.0" const val core_testing: String = "2.1.0"
@ -85,34 +88,32 @@ object Versions {
const val kiwixlib: String = "9.4.0" const val kiwixlib: String = "9.4.0"
const val material: String = "1.2.0" const val material: String = "1.2.0" // available: "1.2.1"
const val multidex: String = "2.0.1" const val multidex: String = "2.0.1"
const val barista: String = "2.7.1" // available: "3.5.0" const val barista: String = "2.7.1" // available: "3.6.0"
const val xfetch2: String = "3.1.4" const val xfetch2: String = "3.1.4" // available: "3.1.5"
const val jsr305: String = "3.0.2" const val jsr305: String = "3.0.2"
const val ktlint: String = "0.37.2" const val ktlint: String = "0.36.0" // available: "0.39.0"
const val rxjava: String = "2.2.19" const val rxjava: String = "2.2.19"
const val webkit: String = "1.2.0" const val webkit: String = "1.2.0" // available: "1.3.0"
const val aapt2: String = "4.0.1-6197926" const val aapt2: String = "4.0.1-6197926"
const val junit: String = "1.1.1" const val junit: String = "1.1.1" // available: "1.1.2"
const val navigation: String = "2.3.0"
/** /**
* Current version: "6.2" * Current version: "6.2"
* See issue 19: How to update Gradle itself? * See issue 19: How to update Gradle itself?
* https://github.com/jmfayard/buildSrcVersions/issues/19 * https://github.com/jmfayard/buildSrcVersions/issues/19
*/ */
const val gradleLatestVersion: String = "6.5.1" const val gradleLatestVersion: String = "6.6.1"
} }
/** /**

View File

@ -161,9 +161,9 @@ class AllProjectConfigurer {
implementation(Libs.constraintlayout) implementation(Libs.constraintlayout)
implementation(Libs.multidex) implementation(Libs.multidex)
// navigation // navigation
implementation(Libs.navigation_kotlin_fragment) implementation(Libs.navigation_fragment_ktx)
implementation(Libs.navigation_kotlin_ui) implementation(Libs.navigation_ui_ktx)
androidTestImplementation(Libs.navigation_kotlin_testing) androidTestImplementation(Libs.navigation_testing)
implementation(Libs.okhttp) implementation(Libs.okhttp)
implementation(Libs.logging_interceptor) implementation(Libs.logging_interceptor)
implementation(Libs.retrofit) implementation(Libs.retrofit)

View File

@ -61,4 +61,6 @@ dependencies {
implementation(Libs.objectbox_kotlin) implementation(Libs.objectbox_kotlin)
implementation(Libs.objectbox_rxjava) implementation(Libs.objectbox_rxjava)
implementation(Libs.webkit) implementation(Libs.webkit)
testImplementation(Libs.kotlinx_coroutines_test)
implementation(Libs.kotlinx_coroutines_android)
} }

View File

@ -0,0 +1,36 @@
/*
* Kiwix Android
* Copyright (c) 2020 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.core.dao
import io.objectbox.query.Query
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
class FlowBuilder @Inject constructor() {
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> buildCallbackFlow(query: Query<T>) =
callbackFlow<List<T>> {
val subscription = query.subscribe()
.observer { sendBlocking(it) }
awaitClose(subscription::cancel)
}
}

View File

@ -19,14 +19,20 @@ package org.kiwix.kiwixmobile.core.dao
import io.objectbox.Box import io.objectbox.Box
import io.objectbox.kotlin.query import io.objectbox.kotlin.query
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.map
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_ import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_
import org.kiwix.kiwixmobile.core.data.local.entity.RecentSearch import org.kiwix.kiwixmobile.core.data.local.entity.RecentSearch
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import javax.inject.Inject import javax.inject.Inject
class NewRecentSearchDao @Inject constructor(private val box: Box<RecentSearchEntity>) { class NewRecentSearchDao @Inject constructor(
fun recentSearches(zimId: String?) = box.asFlowable( private val box: Box<RecentSearchEntity>,
private val flowBuilder: FlowBuilder
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun recentSearches(zimId: String?) = flowBuilder.buildCallbackFlow(
box.query { box.query {
equal(RecentSearchEntity_.zimId, zimId ?: "") equal(RecentSearchEntity_.zimId, zimId ?: "")
orderDesc(RecentSearchEntity_.id) orderDesc(RecentSearchEntity_.id)

View File

@ -23,6 +23,7 @@ import dagger.Provides
import io.objectbox.BoxStore import io.objectbox.BoxStore
import io.objectbox.kotlin.boxFor import io.objectbox.kotlin.boxFor
import org.kiwix.kiwixmobile.core.dao.FetchDownloadDao import org.kiwix.kiwixmobile.core.dao.FetchDownloadDao
import org.kiwix.kiwixmobile.core.dao.FlowBuilder
import org.kiwix.kiwixmobile.core.dao.HistoryDao import org.kiwix.kiwixmobile.core.dao.HistoryDao
import org.kiwix.kiwixmobile.core.dao.NewBookDao import org.kiwix.kiwixmobile.core.dao.NewBookDao
import org.kiwix.kiwixmobile.core.dao.NewBookmarksDao import org.kiwix.kiwixmobile.core.dao.NewBookmarksDao
@ -57,8 +58,10 @@ open class DatabaseModule {
@Provides @Singleton fun providesNewBookmarksDao(boxStore: BoxStore): NewBookmarksDao = @Provides @Singleton fun providesNewBookmarksDao(boxStore: BoxStore): NewBookmarksDao =
NewBookmarksDao(boxStore.boxFor()) NewBookmarksDao(boxStore.boxFor())
@Provides @Singleton fun providesNewRecentSearchDao(boxStore: BoxStore): NewRecentSearchDao = @Provides @Singleton fun providesNewRecentSearchDao(
NewRecentSearchDao(boxStore.boxFor()) boxStore: BoxStore,
flowBuilder: FlowBuilder
): NewRecentSearchDao = NewRecentSearchDao(boxStore.boxFor(), flowBuilder)
@Provides @Singleton fun providesFetchDownloadDao( @Provides @Singleton fun providesFetchDownloadDao(
boxStore: BoxStore, boxStore: BoxStore,

View File

@ -24,21 +24,23 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MenuItem.OnActionExpandListener import android.view.MenuItem.OnActionExpandListener
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.Observer import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.reactivex.disposables.CompositeDisposable import kotlinx.android.synthetic.main.activity_search.searchLoadingIndicator
import kotlinx.android.synthetic.main.activity_search.searchViewAnimator import kotlinx.android.synthetic.main.activity_search.searchNoResults
import kotlinx.android.synthetic.main.activity_search.search_list import kotlinx.android.synthetic.main.activity_search.search_list
import kotlinx.android.synthetic.main.layout_toolbar.toolbar import kotlinx.android.synthetic.main.layout_toolbar.toolbar
import kotlinx.coroutines.flow.collect
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.R.id import org.kiwix.kiwixmobile.core.R.id
import org.kiwix.kiwixmobile.core.base.BaseActivity import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.di.components.CoreComponent import org.kiwix.kiwixmobile.core.di.components.CoreComponent
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.coreActivityComponent import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.coreActivityComponent
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.viewModel import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.viewModel
import org.kiwix.kiwixmobile.core.extensions.setDistinctDisplayedChild
import org.kiwix.kiwixmobile.core.search.adapter.SearchAdapter import org.kiwix.kiwixmobile.core.search.adapter.SearchAdapter
import org.kiwix.kiwixmobile.core.search.adapter.SearchDelegate.RecentSearchDelegate import org.kiwix.kiwixmobile.core.search.adapter.SearchDelegate.RecentSearchDelegate
import org.kiwix.kiwixmobile.core.search.adapter.SearchDelegate.ZimSearchResultDelegate import org.kiwix.kiwixmobile.core.search.adapter.SearchDelegate.ZimSearchResultDelegate
@ -49,13 +51,11 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.CreatedWithIntent
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ExitedSearch import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ExitedSearch
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemClick import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemLongClick import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnItemLongClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchState
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchViewModel import org.kiwix.kiwixmobile.core.search.viewmodel.SearchViewModel
import org.kiwix.kiwixmobile.core.search.viewmodel.State
import org.kiwix.kiwixmobile.core.search.viewmodel.State.NoResults
import org.kiwix.kiwixmobile.core.search.viewmodel.State.Results
import org.kiwix.kiwixmobile.core.utils.SimpleTextListener import org.kiwix.kiwixmobile.core.utils.SimpleTextListener
import javax.inject.Inject import javax.inject.Inject
@ -68,7 +68,6 @@ class SearchActivity : BaseActivity() {
private lateinit var searchInTextMenuItem: MenuItem private lateinit var searchInTextMenuItem: MenuItem
private val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) } private val searchViewModel by lazy { viewModel<SearchViewModel>(viewModelFactory) }
private val compositeDisposable = CompositeDisposable()
private val searchAdapter: SearchAdapter by lazy { private val searchAdapter: SearchAdapter by lazy {
SearchAdapter( SearchAdapter(
RecentSearchDelegate(::onItemClick, ::onItemClickNewTab) { RecentSearchDelegate(::onItemClick, ::onItemClickNewTab) {
@ -93,12 +92,9 @@ class SearchActivity : BaseActivity() {
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
setHasFixedSize(true) setHasFixedSize(true)
} }
compositeDisposable.add(searchViewModel.effects.subscribe { it.invokeWith(this) }) lifecycleScope.launchWhenCreated {
searchViewModel.effects.collect { it.invokeWith(this@SearchActivity) }
} }
override fun onDestroy() {
compositeDisposable.clear()
super.onDestroy()
} }
override fun finish() { override fun finish() {
@ -127,29 +123,19 @@ class SearchActivity : BaseActivity() {
searchViewModel.actions.offer(ClickedSearchInText) searchViewModel.actions.offer(ClickedSearchInText)
true true
} }
lifecycleScope.launchWhenCreated {
searchViewModel.state.observe(this, Observer(::render)) searchViewModel.state.collect { render(it) }
}
searchViewModel.actions.offer(CreatedWithIntent(intent)) searchViewModel.actions.offer(CreatedWithIntent(intent))
return true return true
} }
private fun render(state: State) { private fun render(state: SearchState) {
searchInTextMenuItem.isVisible = state.searchOrigin == FromWebView searchInTextMenuItem.isVisible = state.searchOrigin == FromWebView
when (state) { searchInTextMenuItem.isEnabled = state.searchTerm.isNotBlank()
is Results -> { searchLoadingIndicator.isShowing(state.isLoading)
searchViewAnimator.setDistinctDisplayedChild(0) searchNoResults.isVisible = state.visibleResults.isEmpty()
searchAdapter.items = state.values searchAdapter.items = state.visibleResults
render(state.searchString)
}
is NoResults -> {
searchViewAnimator.setDistinctDisplayedChild(1)
render(state.searchString)
}
}
}
private fun render(searchString: String) {
searchInTextMenuItem.isEnabled = searchString.isNotBlank()
} }
private fun onItemClick(it: SearchListItem) { private fun onItemClick(it: SearchListItem) {
@ -165,3 +151,11 @@ class SearchActivity : BaseActivity() {
searchViewModel.actions.offer(ActivityResultReceived(requestCode, resultCode, data)) searchViewModel.actions.offer(ActivityResultReceived(requestCode, resultCode, data))
} }
} }
private fun ContentLoadingProgressBar.isShowing(show: Boolean) {
if (show) {
show()
} else {
hide()
}
}

View File

@ -18,33 +18,49 @@
package org.kiwix.kiwixmobile.core.search.viewmodel package org.kiwix.kiwixmobile.core.search.viewmodel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import javax.inject.Inject import javax.inject.Inject
interface SearchResultGenerator { interface SearchResultGenerator {
fun generateSearchResults(searchTerm: String): List<SearchListItem> suspend fun generateSearchResults(
searchTerm: String,
zimFileReader: ZimFileReader?
): List<SearchListItem>
} }
class ZimSearchResultGenerator @Inject constructor( class ZimSearchResultGenerator @Inject constructor() : SearchResultGenerator {
private val zimReaderContainer: ZimReaderContainer
) : SearchResultGenerator {
override fun generateSearchResults(searchTerm: String) =
if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimReaderContainer.copyReader())
else emptyList()
private fun readResultsFromZim( override suspend fun generateSearchResults(searchTerm: String, zimFileReader: ZimFileReader?) =
it: String, withContext(Dispatchers.IO) {
if (searchTerm.isNotEmpty()) readResultsFromZim(searchTerm, zimFileReader)
else emptyList()
}
private suspend fun readResultsFromZim(
searchTerm: String,
reader: ZimFileReader? reader: ZimFileReader?
) = ) =
reader?.searchSuggestions(it, 200).run { suggestionResults(reader) } reader.also { yield() }
?.searchSuggestions(searchTerm, 200)
.also { yield() }
.run { suggestionResults(reader) }
private fun suggestionResults(reader: ZimFileReader?) = generateSequence { private suspend fun suggestionResults(reader: ZimFileReader?) = createList {
reader?.getNextSuggestion()?.let { ZimSearchResultListItem(it.title) } yield()
reader?.getNextSuggestion()
?.let { ZimSearchResultListItem(it.title) }
} }
.distinct() .distinct()
.toList() .toList()
.also { reader?.dispose() }
private suspend fun <T> createList(readSearchResult: suspend () -> T?): List<T> {
return mutableListOf<T>().apply {
while (true) readSearchResult()?.let(::add) ?: break
}
}
} }

View File

@ -20,21 +20,21 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
data class SearchState(
val searchTerm: String,
val searchResultsWithTerm: SearchResultsWithTerm,
val recentResults: List<SearchListItem.RecentSearchListItem>,
val searchOrigin: SearchOrigin
) {
val visibleResults = when {
searchTerm.isNotEmpty() -> searchResultsWithTerm.results
else -> recentResults
}
val isLoading = searchTerm != searchResultsWithTerm.searchTerm
}
enum class SearchOrigin { enum class SearchOrigin {
FromWebView, FromWebView,
FromTabView FromTabView
} }
sealed class State {
abstract val searchString: String
abstract val searchOrigin: SearchOrigin
data class Results(
override val searchString: String,
val values: List<SearchListItem>,
override val searchOrigin: SearchOrigin
) : State()
data class NoResults(override val searchString: String, override val searchOrigin: SearchOrigin) :
State()
}

View File

@ -18,15 +18,20 @@
package org.kiwix.kiwixmobile.core.search.viewmodel package org.kiwix.kiwixmobile.core.search.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import io.reactivex.Flowable import androidx.lifecycle.viewModelScope
import io.reactivex.disposables.CompositeDisposable import kotlinx.coroutines.ExperimentalCoroutinesApi
import io.reactivex.disposables.Disposable import kotlinx.coroutines.channels.Channel
import io.reactivex.functions.Function4 import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import io.reactivex.processors.BehaviorProcessor import kotlinx.coroutines.channels.consumeEach
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.sendBlocking
import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.NewRecentSearchDao import org.kiwix.kiwixmobile.core.dao.NewRecentSearchDao
@ -45,8 +50,6 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeec
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.State.NoResults
import org.kiwix.kiwixmobile.core.search.viewmodel.State.Results
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.DeleteRecentSearch import org.kiwix.kiwixmobile.core.search.viewmodel.effects.DeleteRecentSearch
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.FinishActivity import org.kiwix.kiwixmobile.core.search.viewmodel.effects.FinishActivity
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.OpenSearchItem import org.kiwix.kiwixmobile.core.search.viewmodel.effects.OpenSearchItem
@ -57,118 +60,85 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.effects.SearchIntentProcessin
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
private const val DEBOUNCE_MS = 500L @OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val recentSearchDao: NewRecentSearchDao, private val recentSearchDao: NewRecentSearchDao,
private val zimReaderContainer: ZimReaderContainer, private val zimReaderContainer: ZimReaderContainer,
private val searchResultGenerator: SearchResultGenerator private val searchResultGenerator: SearchResultGenerator
) : ViewModel() { ) : ViewModel() {
val state = MutableLiveData<State>().apply { value = NoResults("", FromWebView) } private val initialState: SearchState =
val effects = PublishProcessor.create<SideEffect<*>>() SearchState("", SearchResultsWithTerm("", emptyList()), emptyList(), FromWebView)
val actions = PublishProcessor.create<Action>() val state: MutableStateFlow<SearchState> = MutableStateFlow(initialState)
private val filter = BehaviorProcessor.createDefault("") private val _effects = Channel<SideEffect<*>>()
private val searchOrigin = BehaviorProcessor.createDefault(FromWebView) val effects = _effects.receiveAsFlow()
private val searchResults = PublishProcessor.create<List<SearchListItem>>() val actions = Channel<Action>(Channel.UNLIMITED)
private val filter = ConflatedBroadcastChannel("")
private val compositeDisposable = CompositeDisposable() private val searchOrigin = ConflatedBroadcastChannel(FromWebView)
private var searchTask: Disposable? = null
init { init {
compositeDisposable.addAll( viewModelScope.launch { reducer() }
viewStateReducer(), viewModelScope.launch { actionMapper() }
actionMapper(),
searchResultEventsFromZimReader()
)
} }
override fun onCleared() { private suspend fun reducer() {
compositeDisposable.clear() combine(
super.onCleared() filter.asFlow(),
searchResults(),
recentSearchDao.recentSearches(zimReaderContainer.id),
searchOrigin.asFlow()
) { searchTerm, searchResultsWithTerm, recentResults, searchOrigin ->
SearchState(searchTerm, searchResultsWithTerm, recentResults, searchOrigin)
}
.collect { state.value = it }
} }
private fun actionMapper() = actions.map { private fun searchResults() = filter.asFlow()
.mapLatest {
val zimFileReader = zimReaderContainer.copyReader()
try {
SearchResultsWithTerm(it, searchResultGenerator.generateSearchResults(it, zimFileReader))
} finally {
zimFileReader?.dispose()
}
}
private suspend fun actionMapper() = actions.consumeEach {
when (it) { when (it) {
ExitedSearch -> effects.offer(FinishActivity) ExitedSearch -> _effects.offer(FinishActivity)
is OnItemClick -> saveSearchAndOpenItem(it.searchListItem, false) is OnItemClick -> saveSearchAndOpenItem(it.searchListItem, false)
is OnOpenInNewTabClick -> saveSearchAndOpenItem(it.searchListItem, true) is OnOpenInNewTabClick -> saveSearchAndOpenItem(it.searchListItem, true)
is OnItemLongClick -> showDeleteDialog(it) is OnItemLongClick -> showDeleteDialog(it)
is Filter -> filter.offer(it.term) is Filter -> filter.sendBlocking(it.term)
ClickedSearchInText -> searchPreviousScreenWhenStateIsValid() ClickedSearchInText -> searchPreviousScreenWhenStateIsValid()
is ConfirmedDelete -> deleteItemAndShowToast(it) is ConfirmedDelete -> deleteItemAndShowToast(it)
is CreatedWithIntent -> effects.offer(SearchIntentProcessing(it.intent, actions)) is CreatedWithIntent -> _effects.offer(SearchIntentProcessing(it.intent, actions))
ReceivedPromptForSpeechInput -> effects.offer(StartSpeechInput(actions)) ReceivedPromptForSpeechInput -> _effects.offer(StartSpeechInput(actions))
StartSpeechInputFailed -> effects.offer(ShowToast(R.string.speech_not_supported)) StartSpeechInputFailed -> _effects.offer(ShowToast(R.string.speech_not_supported))
is ActivityResultReceived -> is ActivityResultReceived ->
effects.offer(ProcessActivityResult(it.requestCode, it.resultCode, it.data, actions)) _effects.offer(ProcessActivityResult(it.requestCode, it.resultCode, it.data, actions))
is ScreenWasStartedFrom -> searchOrigin.offer(it.searchOrigin) is ScreenWasStartedFrom -> searchOrigin.sendBlocking(it.searchOrigin)
}
} }
}.subscribe(
{},
Throwable::printStackTrace
)
private fun deleteItemAndShowToast(it: ConfirmedDelete) { private fun deleteItemAndShowToast(it: ConfirmedDelete) {
effects.offer(DeleteRecentSearch(it.searchListItem, recentSearchDao)) _effects.offer(DeleteRecentSearch(it.searchListItem, recentSearchDao))
effects.offer(ShowToast(R.string.delete_specific_search_toast)) _effects.offer(ShowToast(R.string.delete_specific_search_toast))
} }
private fun searchPreviousScreenWhenStateIsValid(): Any = private fun searchPreviousScreenWhenStateIsValid(): Any =
effects.offer(SearchInPreviousScreen(state.value!!.searchString)) _effects.offer(SearchInPreviousScreen(state.value.searchTerm))
private fun showDeleteDialog(longClick: OnItemLongClick) { private fun showDeleteDialog(longClick: OnItemLongClick) {
effects.offer(ShowDeleteSearchDialog(longClick.searchListItem, actions)) _effects.offer(ShowDeleteSearchDialog(longClick.searchListItem, actions))
} }
private fun saveSearchAndOpenItem(searchListItem: SearchListItem, openInNewTab: Boolean) { private fun saveSearchAndOpenItem(searchListItem: SearchListItem, openInNewTab: Boolean) {
effects.offer( _effects.offer(SaveSearchToRecents(recentSearchDao, searchListItem, zimReaderContainer.id))
SaveSearchToRecents(recentSearchDao, searchListItem, zimReaderContainer.id) _effects.offer(OpenSearchItem(searchListItem, openInNewTab))
) }
effects.offer(
OpenSearchItem(searchListItem, openInNewTab)
)
} }
private fun viewStateReducer() = data class SearchResultsWithTerm(val searchTerm: String, val results: List<SearchListItem>)
Flowable.combineLatest(
recentSearchDao.recentSearches(zimReaderContainer.id),
searchResults,
filter,
searchOrigin,
Function4(this::reduce)
).subscribe(state::postValue, Throwable::printStackTrace)
private fun reduce(
recentSearchResults: List<SearchListItem>,
zimSearchResults: List<SearchListItem>,
searchString: String,
searchOrigin: SearchOrigin
) = when {
searchString.isNotEmpty() && zimSearchResults.isNotEmpty() ->
Results(searchString, zimSearchResults, searchOrigin)
searchString.isEmpty() && recentSearchResults.isNotEmpty() ->
Results(searchString, recentSearchResults, searchOrigin)
else -> NoResults(searchString, searchOrigin)
}
private fun searchResultEventsFromZimReader() = filter
.distinctUntilChanged()
.debounce(DEBOUNCE_MS, TimeUnit.MILLISECONDS)
.subscribe(::performSearch)
private fun performSearch(searchTerm: String) {
compositeDisposable.add(
Flowable.fromCallable { searchResultGenerator.generateSearchResults(searchTerm) }
.subscribeOn(Schedulers.io())
.subscribe { searchResults.offer(it) }
.also {
searchTask?.dispose()
searchTask = it
}
)
}
}

View File

@ -22,7 +22,7 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.speech.RecognizerIntent import android.speech.RecognizerIntent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
@ -31,7 +31,7 @@ data class ProcessActivityResult(
private val requestCode: Int, private val requestCode: Int,
private val resultCode: Int, private val resultCode: Int,
private val data: Intent?, private val data: Intent?,
private val actions: PublishProcessor<Action> private val actions: Channel<Action>
) : SideEffect<Unit> { ) : SideEffect<Unit> {
override fun invokeWith(activity: AppCompatActivity) { override fun invokeWith(activity: AppCompatActivity) {
if (requestCode == StartSpeechInput.REQ_CODE_SPEECH_INPUT && if (requestCode == StartSpeechInput.REQ_CODE_SPEECH_INPUT &&

View File

@ -22,7 +22,7 @@ import android.annotation.TargetApi
import android.content.Intent import android.content.Intent
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter import org.kiwix.kiwixmobile.core.search.viewmodel.Action.Filter
@ -36,7 +36,7 @@ import org.kiwix.kiwixmobile.core.utils.TAG_FROM_TAB_SWITCHER
data class SearchIntentProcessing( data class SearchIntentProcessing(
private val intent: Intent?, private val intent: Intent?,
private val actions: PublishProcessor<Action> private val actions: Channel<Action>
) : SideEffect<Unit> { ) : SideEffect<Unit> {
@TargetApi(VERSION_CODES.M) @TargetApi(VERSION_CODES.M)
override fun invokeWith(activity: AppCompatActivity) { override fun invokeWith(activity: AppCompatActivity) {

View File

@ -19,7 +19,7 @@
package org.kiwix.kiwixmobile.core.search.viewmodel.effects package org.kiwix.kiwixmobile.core.search.viewmodel.effects
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.SearchActivity import org.kiwix.kiwixmobile.core.search.SearchActivity
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem
@ -30,7 +30,7 @@ import javax.inject.Inject
data class ShowDeleteSearchDialog( data class ShowDeleteSearchDialog(
private val searchListItem: SearchListItem, private val searchListItem: SearchListItem,
private val actions: PublishProcessor<Action> private val actions: Channel<Action>
) : SideEffect<Unit> { ) : SideEffect<Unit> {
@Inject lateinit var dialogShower: DialogShower @Inject lateinit var dialogShower: DialogShower

View File

@ -22,14 +22,14 @@ import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.speech.RecognizerIntent import android.speech.RecognizerIntent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed
import java.util.Locale import java.util.Locale
data class StartSpeechInput(private val actions: PublishProcessor<Action>) : SideEffect<Unit> { data class StartSpeechInput(private val actions: Channel<Action>) : SideEffect<Unit> {
override fun invokeWith(activity: AppCompatActivity) { override fun invokeWith(activity: AppCompatActivity) {
try { try {

View File

@ -1,6 +1,8 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:animateLayoutChanges="true"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
android:orientation="vertical"> android:orientation="vertical">
@ -16,26 +18,28 @@
<include layout="@layout/layout_toolbar" /> <include layout="@layout/layout_toolbar" />
</RelativeLayout> </RelativeLayout>
<ViewAnimator
android:id="@+id/searchViewAnimator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_list" android:id="@+id/search_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
</LinearLayout>
<TextView <TextView
android:id="@+id/searchNoResults"
style="@style/no_content_text" style="@style/no_content_text"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:text="@string/no_results" /> android:text="@string/no_results" />
</ViewAnimator> <androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/searchLoadingIndicator"
</LinearLayout> style="?android:attr/progressBarStyleLarge"
android:visibility="gone"
tools:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -20,60 +20,72 @@ package org.kiwix.kiwixmobile.core.dao
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify import io.mockk.verify
import io.objectbox.Box import io.objectbox.Box
import io.objectbox.query.Query import io.objectbox.query.Query
import io.objectbox.query.QueryBuilder import io.objectbox.query.QueryBuilder
import io.objectbox.rx.RxQuery import kotlinx.coroutines.flow.Flow
import io.reactivex.Observable import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runBlockingTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity
import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_ import org.kiwix.kiwixmobile.core.dao.entities.RecentSearchEntity_
import org.kiwix.kiwixmobile.core.data.local.entity.RecentSearch import org.kiwix.kiwixmobile.core.data.local.entity.RecentSearch
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.test
import org.kiwix.sharedFunctions.recentSearchEntity import org.kiwix.sharedFunctions.recentSearchEntity
internal class NewRecentSearchDaoTest { internal class NewRecentSearchDaoTest {
private val box: Box<RecentSearchEntity> = mockk(relaxed = true) private val box: Box<RecentSearchEntity> = mockk(relaxed = true)
private val newRecentSearchDao = NewRecentSearchDao(box) private val flowBuilder: FlowBuilder = mockk()
private val newRecentSearchDao = NewRecentSearchDao(box, flowBuilder)
@Nested @Nested
inner class RecentSearchTests { inner class RecentSearchTests {
@Test @Test
fun `recentSearches searches by Id passed`() { fun `recentSearches searches by Id passed`() = runBlockingTest {
val zimId = "id" val zimId = "id"
val queryResult = listOf<RecentSearchEntity>(recentSearchEntity()) val queryResult = listOf<RecentSearchEntity>(recentSearchEntity())
expectFromRecentSearches(queryResult, zimId) expectFromRecentSearches(queryResult, zimId)
newRecentSearchDao.recentSearches(zimId).test() newRecentSearchDao.recentSearches(zimId)
.test(this)
.assertValues(queryResult.map { RecentSearchListItem(it.searchTerm) }) .assertValues(queryResult.map { RecentSearchListItem(it.searchTerm) })
.finish()
} }
@Test @Test
fun `recentSearches searches with blank Id if null passed`() { fun `recentSearches searches with blank Id if null passed`() = runBlockingTest {
val queryResult = listOf<RecentSearchEntity>(recentSearchEntity()) val queryResult = listOf<RecentSearchEntity>(recentSearchEntity())
expectFromRecentSearches(queryResult, "") expectFromRecentSearches(queryResult, "")
newRecentSearchDao.recentSearches(null).test() newRecentSearchDao.recentSearches(null)
.test(this)
.assertValues(queryResult.map { RecentSearchListItem(it.searchTerm) }) .assertValues(queryResult.map { RecentSearchListItem(it.searchTerm) })
.finish()
} }
@Test @Test
fun `recentSearches searches returns distinct entities by searchTerm`() { fun `recentSearches searches returns distinct entities by searchTerm`() = runBlockingTest {
val queryResult = listOf<RecentSearchEntity>(recentSearchEntity(), recentSearchEntity()) val queryResult = listOf<RecentSearchEntity>(recentSearchEntity(), recentSearchEntity())
expectFromRecentSearches(queryResult, "") expectFromRecentSearches(queryResult, "")
newRecentSearchDao.recentSearches("").test() newRecentSearchDao.recentSearches("")
.test(this)
.assertValues(queryResult.take(1).map { RecentSearchListItem(it.searchTerm) }) .assertValues(queryResult.take(1).map { RecentSearchListItem(it.searchTerm) })
.finish()
} }
@Test @Test
fun `recentSearches searches returns a limitedNumber of entities`() { fun `recentSearches searches returns a limitedNumber of entities`() = runBlockingTest {
val searchResults: List<RecentSearchEntity> = val searchResults: List<RecentSearchEntity> =
(0..200).map { recentSearchEntity(searchTerm = "$it") } (0..200).map { recentSearchEntity(searchTerm = "$it") }
expectFromRecentSearches(searchResults, "") expectFromRecentSearches(searchResults, "")
newRecentSearchDao.recentSearches("").test() newRecentSearchDao.recentSearches("")
.test(this)
.assertValue { it.size == 100 } .assertValue { it.size == 100 }
.finish()
} }
private fun expectFromRecentSearches(queryResult: List<RecentSearchEntity>, zimId: String) { private fun expectFromRecentSearches(queryResult: List<RecentSearchEntity>, zimId: String) {
@ -83,8 +95,7 @@ internal class NewRecentSearchDaoTest {
every { queryBuilder.orderDesc(RecentSearchEntity_.id) } returns queryBuilder every { queryBuilder.orderDesc(RecentSearchEntity_.id) } returns queryBuilder
val query = mockk<Query<RecentSearchEntity>>() val query = mockk<Query<RecentSearchEntity>>()
every { queryBuilder.build() } returns query every { queryBuilder.build() } returns query
mockkStatic(RxQuery::class) every { flowBuilder.buildCallbackFlow(query) } returns flowOf(queryResult)
every { RxQuery.observable(query) } returns Observable.just(queryResult)
} }
} }
@ -123,3 +134,13 @@ internal class NewRecentSearchDaoTest {
verify { box.put(listOf(recentSearchEntity(searchTerm = term, zimId = id))) } verify { box.put(listOf(recentSearchEntity(searchTerm = term, zimId = id))) }
} }
} }
private suspend fun <T> Flow<T>.assertValue(function: (T) -> Boolean) {
val value = toList().last()
val result = function(value)
assertThat(result).isTrue()
}
suspend inline fun <T> Flow<T>.assertValues(expectedValues: List<T>) {
assertThat(toList()).containsExactlyElementsOf(expectedValues)
}

View File

@ -0,0 +1,79 @@
/*
* Kiwix Android
* Copyright (c) 2020 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.core.search.viewmodel
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
internal class SearchStateTest {
@Test
internal fun `visibleResults use searchResults when searchTerm is not empty`() {
val results = listOf(ZimSearchResultListItem(""))
assertThat(
SearchState(
"notEmpty",
SearchResultsWithTerm("", results),
emptyList(),
FromWebView
).visibleResults
).isEqualTo(results)
}
@Test
internal fun `visibleResults use recentResults when searchTerm is empty`() {
val results = listOf(RecentSearchListItem(""))
assertThat(
SearchState(
"",
SearchResultsWithTerm("", emptyList()),
results,
FromWebView
).visibleResults
).isEqualTo(results)
}
@Test
internal fun `isLoading when searchTerm is not equal to ResultTerm`() {
assertThat(
SearchState(
"",
SearchResultsWithTerm("notEqual", emptyList()),
emptyList(),
FromWebView
).isLoading
).isTrue()
}
@Test
internal fun `is not Loading when searchTerm is equal to ResultTerm`() {
val searchTerm = "equal"
assertThat(
SearchState(
searchTerm,
SearchResultsWithTerm(searchTerm, emptyList()),
emptyList(),
FromWebView
).isLoading
).isFalse()
}
}

View File

@ -19,21 +19,34 @@
package org.kiwix.kiwixmobile.core.search.viewmodel package org.kiwix.kiwixmobile.core.search.viewmodel
import android.content.Intent import android.content.Intent
import com.jraska.livedata.test
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.CoroutineScope
import io.reactivex.schedulers.TestScheduler import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.setMain
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.R.string import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.base.SideEffect import org.kiwix.kiwixmobile.core.base.SideEffect
import org.kiwix.kiwixmobile.core.dao.NewRecentSearchDao import org.kiwix.kiwixmobile.core.dao.NewRecentSearchDao
import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
@ -49,10 +62,7 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.OnOpenInNewTabClick
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeechInput import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeechInput
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed import org.kiwix.kiwixmobile.core.search.viewmodel.Action.StartSpeechInputFailed
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromTabView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.search.viewmodel.State.NoResults
import org.kiwix.kiwixmobile.core.search.viewmodel.State.Results
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.DeleteRecentSearch import org.kiwix.kiwixmobile.core.search.viewmodel.effects.DeleteRecentSearch
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.FinishActivity import org.kiwix.kiwixmobile.core.search.viewmodel.effects.FinishActivity
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.OpenSearchItem import org.kiwix.kiwixmobile.core.search.viewmodel.effects.OpenSearchItem
@ -63,166 +73,83 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.effects.SearchIntentProcessin
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowDeleteSearchDialog
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast import org.kiwix.kiwixmobile.core.search.viewmodel.effects.ShowToast
import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput import org.kiwix.kiwixmobile.core.search.viewmodel.effects.StartSpeechInput
import org.kiwix.sharedFunctions.InstantExecutorExtension
import org.kiwix.sharedFunctions.resetSchedulers
import org.kiwix.sharedFunctions.setScheduler
import java.util.concurrent.TimeUnit.MILLISECONDS
@ExtendWith(InstantExecutorExtension::class) @OptIn(ExperimentalCoroutinesApi::class)
internal class SearchViewModelTest { internal class SearchViewModelTest {
private val recentSearchDao: NewRecentSearchDao = mockk() private val recentSearchDao: NewRecentSearchDao = mockk()
private val zimReaderContainer: ZimReaderContainer = mockk() private val zimReaderContainer: ZimReaderContainer = mockk()
private val searchResultGenerator: SearchResultGenerator = mockk() private val searchResultGenerator: SearchResultGenerator = mockk()
private val zimFileReader: ZimFileReader = mockk()
private val testDispatcher = TestCoroutineDispatcher()
lateinit var viewModel: SearchViewModel lateinit var viewModel: SearchViewModel
private val testScheduler = TestScheduler()
init {
setScheduler(testScheduler)
}
@AfterAll @AfterAll
fun teardown() { fun teardown() {
resetSchedulers() Dispatchers.resetMain()
} }
private val recentsFromDb: PublishProcessor<List<RecentSearchListItem>> = private lateinit var recentsFromDb: Channel<List<RecentSearchListItem>>
PublishProcessor.create()
@BeforeEach @BeforeEach
fun init() { fun init() {
Dispatchers.resetMain()
Dispatchers.setMain(testDispatcher)
clearAllMocks() clearAllMocks()
recentsFromDb = Channel(kotlinx.coroutines.channels.Channel.UNLIMITED)
every { zimReaderContainer.copyReader() } returns zimFileReader
coEvery {
searchResultGenerator.generateSearchResults("", zimFileReader)
} returns emptyList()
every { zimReaderContainer.id } returns "id" every { zimReaderContainer.id } returns "id"
every { recentSearchDao.recentSearches("id") } returns recentsFromDb every { recentSearchDao.recentSearches("id") } returns recentsFromDb.consumeAsFlow()
viewModel = SearchViewModel(recentSearchDao, zimReaderContainer, searchResultGenerator) viewModel = SearchViewModel(recentSearchDao, zimReaderContainer, searchResultGenerator)
} }
@Nested @Nested
inner class StateTests { inner class StateTests {
@Test @Test
fun `initial state is Initialising`() { fun `initial state is Initialising`() = runBlockingTest {
viewModel.state.test().assertValue(NoResults("", FromWebView)) viewModel.state.test(this).assertValue(
SearchState("", SearchResultsWithTerm("", emptyList()), emptyList(), FromWebView)
).finish()
} }
@Test @Test
fun `non empty search term with search results shows Results`() { fun `SearchState combines sources from inputs`() = runBlockingTest {
val item = ZimSearchResultListItem("") val item = ZimSearchResultListItem("")
val searchTerm = "searchTerm" val searchTerm = "searchTerm"
val searchOrigin = FromWebView val searchOrigin = FromWebView
viewModel.state.test(this)
.also {
emissionOf( emissionOf(
searchTerm = searchTerm, searchTerm = searchTerm,
searchResults = listOf(item), searchResults = listOf(item),
databaseResults = listOf(RecentSearchListItem("")), databaseResults = listOf(RecentSearchListItem("")),
searchOrigin = searchOrigin searchOrigin = searchOrigin
) )
resultsIn(Results(searchTerm, listOf(item), searchOrigin))
} }
.assertValue(
@Test SearchState(
fun `non empty search string with no search results is NoResults`() { searchTerm,
emissionOf( SearchResultsWithTerm(searchTerm, listOf(item)),
searchTerm = "a", listOf(RecentSearchListItem("")),
searchResults = emptyList(), searchOrigin
databaseResults = listOf(RecentSearchListItem("")),
searchOrigin = FromWebView
) )
resultsIn(NoResults("a", FromWebView))
}
@Test
fun `empty search string with database results shows Results`() {
val item = RecentSearchListItem("")
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = listOf(item),
searchOrigin = FromWebView
) )
resultsIn(Results("", listOf(item), FromWebView)) .finish()
}
@Test
fun `empty search string with no database results is NoResults`() {
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
resultsIn(NoResults("", FromWebView))
}
@Test
fun `duplicate search terms are ignored`() {
val searchString = "a"
val item = ZimSearchResultListItem("")
emissionOf(
searchTerm = searchString,
searchResults = listOf(item),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
viewModel.actions.offer(Filter(searchString))
viewModel.state.test()
.also { testScheduler.advanceTimeBy(100, MILLISECONDS) }
.assertValueHistory(
Results(searchString, listOf(item), FromWebView)
)
}
@Test
fun `only latest search term is used`() {
val item = ZimSearchResultListItem("")
emissionOf(
searchTerm = "a",
searchResults = listOf(item),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
emissionOf(
searchTerm = "b",
searchResults = listOf(item),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
viewModel.state.test()
.also { testScheduler.advanceTimeBy(100, MILLISECONDS) }
.assertValueHistory(Results("b", listOf(item), FromWebView))
}
@Test
fun `webView search origin leads to webView in NoResults`() {
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = emptyList(),
searchOrigin = FromWebView
)
resultsIn(NoResults("", FromWebView))
}
@Test
fun `tabView search origin leads to tabView in Results`() {
emissionOf(
searchTerm = "",
searchResults = listOf(ZimSearchResultListItem("")),
databaseResults = emptyList(),
searchOrigin = FromTabView
)
resultsIn(NoResults("", FromTabView))
} }
} }
@Nested @Nested
inner class ActionMapping { inner class ActionMapping {
@Test @Test
fun `ExitedSearch offers Finish`() { fun `ExitedSearch offers Finish`() = runBlockingTest {
actionResultsInEffects(ExitedSearch, FinishActivity) actionResultsInEffects(ExitedSearch, FinishActivity)
} }
@Test @Test
fun `OnItemClick offers Saves and Opens`() { fun `OnItemClick offers Saves and Opens`() = runBlockingTest {
val searchListItem = RecentSearchListItem("") val searchListItem = RecentSearchListItem("")
actionResultsInEffects( actionResultsInEffects(
OnItemClick(searchListItem), OnItemClick(searchListItem),
@ -232,7 +159,7 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `OnOpenInNewTabClick offers Saves and Opens in new tab`() { fun `OnOpenInNewTabClick offers Saves and Opens in new tab`() = runBlockingTest {
val searchListItem = RecentSearchListItem("") val searchListItem = RecentSearchListItem("")
actionResultsInEffects( actionResultsInEffects(
OnOpenInNewTabClick(searchListItem), OnOpenInNewTabClick(searchListItem),
@ -242,7 +169,7 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `OnItemLongClick offers Saves and Opens`() { fun `OnItemLongClick offers Saves and Opens`() = runBlockingTest {
val searchListItem = RecentSearchListItem("") val searchListItem = RecentSearchListItem("")
actionResultsInEffects( actionResultsInEffects(
OnItemLongClick(searchListItem), OnItemLongClick(searchListItem),
@ -251,12 +178,12 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `ClickedSearchInText offers SearchInPreviousScreen`() { fun `ClickedSearchInText offers SearchInPreviousScreen`() = runBlockingTest {
actionResultsInEffects(ClickedSearchInText, SearchInPreviousScreen("")) actionResultsInEffects(ClickedSearchInText, SearchInPreviousScreen(""))
} }
@Test @Test
fun `ConfirmedDelete offers Delete and Toast`() { fun `ConfirmedDelete offers Delete and Toast`() = runBlockingTest {
val searchListItem = RecentSearchListItem("") val searchListItem = RecentSearchListItem("")
actionResultsInEffects( actionResultsInEffects(
ConfirmedDelete(searchListItem), ConfirmedDelete(searchListItem),
@ -266,7 +193,7 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `CreatedWithIntent offers SearchIntentProcessing`() { fun `CreatedWithIntent offers SearchIntentProcessing`() = runBlockingTest {
val intent = mockk<Intent>() val intent = mockk<Intent>()
actionResultsInEffects( actionResultsInEffects(
CreatedWithIntent(intent), CreatedWithIntent(intent),
@ -275,7 +202,7 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `ReceivedPromptForSpeechInput offers SearchIntentProcessing`() { fun `ReceivedPromptForSpeechInput offers SearchIntentProcessing`() = runBlockingTest {
actionResultsInEffects( actionResultsInEffects(
ReceivedPromptForSpeechInput, ReceivedPromptForSpeechInput,
StartSpeechInput(viewModel.actions) StartSpeechInput(viewModel.actions)
@ -283,7 +210,7 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `StartSpeechInputFailed offers ShowToast`() { fun `StartSpeechInputFailed offers ShowToast`() = runBlockingTest {
actionResultsInEffects( actionResultsInEffects(
StartSpeechInputFailed, StartSpeechInputFailed,
ShowToast(string.speech_not_supported) ShowToast(string.speech_not_supported)
@ -291,40 +218,71 @@ internal class SearchViewModelTest {
} }
@Test @Test
fun `ActivityResultReceived offers ProcessActivityResult`() { fun `ActivityResultReceived offers ProcessActivityResult`() = runBlockingTest {
actionResultsInEffects( actionResultsInEffects(
ActivityResultReceived(0, 1, null), ActivityResultReceived(0, 1, null),
ProcessActivityResult(0, 1, null, viewModel.actions) ProcessActivityResult(0, 1, null, viewModel.actions)
) )
} }
private fun actionResultsInEffects( private fun TestCoroutineScope.actionResultsInEffects(
action: Action, action: Action,
vararg effects: SideEffect<*> vararg effects: SideEffect<*>
) { ) {
viewModel.effects viewModel.effects
.test() .test(this)
.also { viewModel.actions.offer(action) } .also { viewModel.actions.offer(action) }
.assertValues(*effects) .assertValues(*effects)
.finish()
} }
} }
private fun resultsIn(st: State) { private fun TestCoroutineScope.emissionOf(
viewModel.state.test()
.also { testScheduler.advanceTimeBy(100, MILLISECONDS) }
.assertValue(st)
}
private fun emissionOf(
searchTerm: String, searchTerm: String,
searchResults: List<ZimSearchResultListItem>, searchResults: List<ZimSearchResultListItem>,
databaseResults: List<RecentSearchListItem>, databaseResults: List<RecentSearchListItem>,
searchOrigin: SearchOrigin searchOrigin: SearchOrigin
) { ) {
every { searchResultGenerator.generateSearchResults(searchTerm) } returns searchResults
coEvery {
searchResultGenerator.generateSearchResults(searchTerm, zimFileReader)
} returns searchResults
viewModel.actions.offer(Filter(searchTerm)) viewModel.actions.offer(Filter(searchTerm))
recentsFromDb.offer(databaseResults) recentsFromDb.offer(databaseResults)
viewModel.actions.offer(ScreenWasStartedFrom(searchOrigin)) viewModel.actions.offer(ScreenWasStartedFrom(searchOrigin))
testScheduler.advanceTimeBy(500, MILLISECONDS) advanceTimeBy(500)
}
}
fun <T> Flow<T>.test(scope: CoroutineScope) = TestObserver(scope, this)
class TestObserver<T>(
scope: CoroutineScope,
flow: Flow<T>
) {
private val values = mutableListOf<T>()
private val job: Job = scope.launch {
flow.collect {
values.add(it)
}
}
fun assertValues(vararg values: T): TestObserver<T> {
assertThat(values.toList()).containsExactlyElementsOf(this.values)
return this
}
fun assertValue(value: T): TestObserver<T> {
assertThat(values.last()).isEqualTo(value)
return this
}
fun finish() {
job.cancel()
}
fun assertValue(value: (T) -> Boolean): TestObserver<T> {
assertThat(values.last()).satisfies { value(it) }
return this
} }
} }

View File

@ -21,34 +21,27 @@ package org.kiwix.kiwixmobile.core.search.viewmodel
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimFileReader
import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer
import org.kiwix.kiwixmobile.core.search.SearchSuggestion import org.kiwix.kiwixmobile.core.search.SearchSuggestion
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.ZimSearchResultListItem
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
internal class ZimSearchResultGeneratorTest { internal class ZimSearchResultGeneratorTest {
private val sharedPreferenceUtil: SharedPreferenceUtil = mockk()
private val zimReaderContainer: ZimReaderContainer = mockk()
private val zimFileReader: ZimFileReader = mockk() private val zimFileReader: ZimFileReader = mockk()
private val zimSearchResultGenerator: ZimSearchResultGenerator = private val zimSearchResultGenerator: ZimSearchResultGenerator =
ZimSearchResultGenerator(zimReaderContainer) ZimSearchResultGenerator()
@BeforeEach
internal fun setUp() {
every { zimReaderContainer.copyReader() } returns zimFileReader
}
@Test @Test
internal fun `empty search term returns empty list`() { internal fun `empty search term returns empty list`() {
assertThat(zimSearchResultGenerator.generateSearchResults("")) runBlocking {
assertThat(zimSearchResultGenerator.generateSearchResults("", zimFileReader))
.isEqualTo(emptyList<ZimSearchResultListItem>()) .isEqualTo(emptyList<ZimSearchResultListItem>())
} }
}
@Test @Test
internal fun `suggestion results are distinct`() { internal fun `suggestion results are distinct`() {
@ -58,11 +51,12 @@ internal class ZimSearchResultGeneratorTest {
every { zimFileReader.searchSuggestions(" ", 200) } returns true every { zimFileReader.searchSuggestions(" ", 200) } returns true
every { zimFileReader.getNextSuggestion() } returnsMany listOf(item, item, null) every { zimFileReader.getNextSuggestion() } returnsMany listOf(item, item, null)
every { item.title } returns validTitle every { item.title } returns validTitle
assertThat(zimSearchResultGenerator.generateSearchResults(searchTerm)) runBlocking {
assertThat(zimSearchResultGenerator.generateSearchResults(searchTerm, zimFileReader))
.isEqualTo(listOf(ZimSearchResultListItem(validTitle))) .isEqualTo(listOf(ZimSearchResultListItem(validTitle)))
verify { verify {
zimFileReader.searchSuggestions(searchTerm, 200) zimFileReader.searchSuggestions(searchTerm, 200)
zimFileReader.dispose() }
} }
} }
} }

View File

@ -27,7 +27,7 @@ import io.mockk.clearAllMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
@ -37,7 +37,7 @@ internal class ProcessActivityResultTest {
private val activity: AppCompatActivity = mockk() private val activity: AppCompatActivity = mockk()
private val data = mockk<Intent>() private val data = mockk<Intent>()
private val actions = mockk<PublishProcessor<Action>>(relaxed = true) private val actions = mockk<Channel<Action>>(relaxed = true)
private val successfulResult = ProcessActivityResult( private val successfulResult = ProcessActivityResult(
StartSpeechInput.REQ_CODE_SPEECH_INPUT, StartSpeechInput.REQ_CODE_SPEECH_INPUT,
Activity.RESULT_OK, Activity.RESULT_OK,

View File

@ -26,7 +26,7 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import io.mockk.verifySequence import io.mockk.verifySequence
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
@ -35,13 +35,13 @@ import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ReceivedPromptForSpeec
import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom import org.kiwix.kiwixmobile.core.search.viewmodel.Action.ScreenWasStartedFrom
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromTabView import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromTabView
import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView import org.kiwix.kiwixmobile.core.search.viewmodel.SearchOrigin.FromWebView
import org.kiwix.kiwixmobile.core.utils.TAG_FROM_TAB_SWITCHER
import org.kiwix.kiwixmobile.core.utils.EXTRA_SEARCH
import org.kiwix.kiwixmobile.core.utils.EXTRA_IS_WIDGET_VOICE import org.kiwix.kiwixmobile.core.utils.EXTRA_IS_WIDGET_VOICE
import org.kiwix.kiwixmobile.core.utils.EXTRA_SEARCH
import org.kiwix.kiwixmobile.core.utils.TAG_FROM_TAB_SWITCHER
internal class SearchIntentProcessingTest { internal class SearchIntentProcessingTest {
private val actions: PublishProcessor<Action> = mockk(relaxed = true) private val actions: Channel<Action> = mockk(relaxed = true)
private val activity: AppCompatActivity = mockk() private val activity: AppCompatActivity = mockk()

View File

@ -22,7 +22,7 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.search.SearchActivity import org.kiwix.kiwixmobile.core.search.SearchActivity
import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem import org.kiwix.kiwixmobile.core.search.adapter.SearchListItem.RecentSearchListItem
@ -35,7 +35,7 @@ internal class ShowDeleteSearchDialogTest {
@Test @Test
fun `invoke with shows dialog that offers ConfirmedDelete action`() { fun `invoke with shows dialog that offers ConfirmedDelete action`() {
val actions = mockk<PublishProcessor<Action>>(relaxed = true) val actions = mockk<Channel<Action>>(relaxed = true)
val searchListItem = RecentSearchListItem("") val searchListItem = RecentSearchListItem("")
val activity = mockk<SearchActivity>() val activity = mockk<SearchActivity>()
val showDeleteSearchDialog = ShowDeleteSearchDialog(searchListItem, actions) val showDeleteSearchDialog = ShowDeleteSearchDialog(searchListItem, actions)

View File

@ -26,7 +26,7 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkConstructor import io.mockk.mockkConstructor
import io.mockk.verify import io.mockk.verify
import io.reactivex.processors.PublishProcessor import kotlinx.coroutines.channels.Channel
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.search.viewmodel.Action import org.kiwix.kiwixmobile.core.search.viewmodel.Action
@ -35,7 +35,7 @@ import java.util.Locale
internal class StartSpeechInputTest { internal class StartSpeechInputTest {
private val actions = mockk<PublishProcessor<Action>>(relaxed = true) private val actions = mockk<Channel<Action>>(relaxed = true)
@Test @Test
fun `when invoke with throws exception offer StartSpeechInputFailed action`() { fun `when invoke with throws exception offer StartSpeechInputFailed action`() {