From 7986f4184a45dcd00f37b87f4cda26a33f1bc4a5 Mon Sep 17 00:00:00 2001 From: Xizhi Zhu Date: Fri, 25 Nov 2022 15:11:36 +0900 Subject: [PATCH] Refactor Search Activity / ViewModel to use single view state --- .../android/joshua/search/SearchActivity.kt | 214 ++--- .../android/joshua/search/SearchViewModel.kt | 225 +++-- .../joshua/search/SearchViewModelTest.kt | 781 ++++++++++++------ 3 files changed, 807 insertions(+), 413 deletions(-) diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt index 68b8631b..f69a0bd3 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchActivity.kt @@ -20,23 +20,18 @@ import android.app.SearchManager import android.content.Context import android.os.Bundle import android.provider.SearchRecentSuggestions -import android.view.View import androidx.activity.viewModels import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import me.xizzhu.android.joshua.Navigator import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.databinding.ActivitySearchBinding -import me.xizzhu.android.joshua.infra.BaseActivity -import me.xizzhu.android.joshua.infra.onEach -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.onSuccess +import me.xizzhu.android.joshua.infra.BaseActivityV2 import me.xizzhu.android.joshua.ui.dialog import me.xizzhu.android.joshua.ui.fadeIn import me.xizzhu.android.joshua.ui.hideKeyboard @@ -44,120 +39,137 @@ import me.xizzhu.android.joshua.ui.listDialog import me.xizzhu.android.joshua.ui.toast @AndroidEntryPoint -class SearchActivity : BaseActivity(), SearchNoteItem.Callback, SearchVerseItem.Callback, SearchVersePreviewItem.Callback { - private val searchViewModel: SearchViewModel by viewModels() - +class SearchActivity : BaseActivityV2(), SearchNoteItem.Callback, SearchVerseItem.Callback, SearchVersePreviewItem.Callback { private lateinit var searchRecentSuggestions: SearchRecentSuggestions - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - searchRecentSuggestions = RecentSearchProvider.createSearchRecentSuggestions(this) - observeSettings() - observeSearchConfiguration() - observeSearchResults() - initializeListeners() - } + override val viewModel: SearchViewModel by viewModels() - private fun observeSettings() { - searchViewModel.settings().onEach { viewBinding.searchResult.setSettings(it) }.launchIn(lifecycleScope) - } + override fun inflateViewBinding(): ActivitySearchBinding = ActivitySearchBinding.inflate(layoutInflater) - private fun observeSearchConfiguration() { - searchViewModel.searchConfig() - .onSuccess { searchConfiguration -> - viewBinding.toolbar.setSearchConfiguration( - includeOldTestament = searchConfiguration.searchConfig.includeOldTestament, - includeNewTestament = searchConfiguration.searchConfig.includeNewTestament, - includeBookmarks = searchConfiguration.searchConfig.includeBookmarks, - includeHighlights = searchConfiguration.searchConfig.includeHighlights, - includeNotes = searchConfiguration.searchConfig.includeNotes, - ) - } - .launchIn(lifecycleScope) + override fun onViewActionEmitted(viewAction: SearchViewModel.ViewAction) = when (viewAction) { + SearchViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING) } - private fun observeSearchResults() { - searchViewModel.searchResult() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - searchResult.visibility = View.GONE - } - }, - onSuccess = { viewData -> - with(viewBinding) { - searchResult.setItems(viewData.items) - searchResult.scrollToPosition(0) - - if (viewData.instanceSearch) { - searchResult.visibility = View.VISIBLE - } else { - searchResult.fadeIn() - toast(viewData.toast) - } - - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - with(viewBinding) { - loadingSpinner.visibility = View.GONE - dialog(false, R.string.dialog_title_error, R.string.dialog_message_failed_to_search, - { _, _ -> searchViewModel.retrySearch() }, { _, _ -> finish() }) - } - } + override fun onViewStateUpdated(viewState: SearchViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { searchResult.setSettings(it) } + + if (viewState.loading) { + loadingSpinner.fadeIn() + searchResult.isVisible = false + } else { + if (viewState.instantSearch) { + searchResult.isVisible = true + } else { + searchResult.fadeIn() + } + + loadingSpinner.isVisible = false + } + + viewState.searchConfig?.let { searchConfig -> + toolbar.setSearchConfiguration( + includeOldTestament = searchConfig.includeOldTestament, + includeNewTestament = searchConfig.includeNewTestament, + includeBookmarks = searchConfig.includeBookmarks, + includeHighlights = searchConfig.includeHighlights, + includeNotes = searchConfig.includeNotes, + ) + } + + searchResult.setItems(viewState.items) + searchResult.scrollToPosition(0) + + viewState.preview?.let { preview -> + listDialog( + title = preview.title, + settings = preview.settings, + items = preview.items, + selected = preview.currentPosition, + onDismiss = { viewModel.markPreviewAsClosed() } + ) + } + + viewState.toast?.let { + toast(it) + viewModel.markToastAsShown() + } + + when (val error = viewState.error) { + is SearchViewModel.ViewState.Error.PreviewLoadingError -> { + viewModel.markErrorAsShown(error) + + // Very unlikely to fail, so just falls back to open the verse. + openVerse(error.verseToPreview) + } + is SearchViewModel.ViewState.Error.SearchConfigUpdatingError -> { + toast(R.string.toast_unknown_error) + viewModel.markErrorAsShown(error) + } + is SearchViewModel.ViewState.Error.VerseOpeningError -> { + dialog( + cancelable = true, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_select_verse, + onPositive = { _, _ -> openVerse(error.verseToOpen) }, + onDismiss = { viewModel.markErrorAsShown(error) } ) - .launchIn(lifecycleScope) + } + is SearchViewModel.ViewState.Error.VerseSearchingError -> { + dialog( + cancelable = false, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_search, + onPositive = { _, _ -> viewModel.retrySearch() }, + onNegative = { _, _ -> finish() }, + onDismiss = { viewModel.markErrorAsShown(error) } + ) + } + null -> { + // Do nothing + } + } } - private fun initializeListeners() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + searchRecentSuggestions = RecentSearchProvider.createSearchRecentSuggestions(this) + viewBinding.toolbar.initialize( - onIncludeOldTestamentChanged = { searchViewModel.includeOldTestament(it) }, - onIncludeNewTestamentChanged = { searchViewModel.includeNewTestament(it) }, - onIncludeBookmarksChanged = { searchViewModel.includeBookmarks(it) }, - onIncludeHighlightsChanged = { searchViewModel.includeHighlights(it) }, - onIncludeNotesChanged = { searchViewModel.includeNotes(it) }, - onQueryTextListener = object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - searchRecentSuggestions.saveRecentQuery(query, null) - searchViewModel.search(query, false) - viewBinding.toolbar.hideKeyboard() - - // so that the system can close the search suggestion - return false - } - - override fun onQueryTextChange(newText: String): Boolean { - searchViewModel.search(newText, true) - return true - } - }, - clearHistory = { lifecycleScope.launch(Dispatchers.IO) { searchRecentSuggestions.clearHistory() } } + onIncludeOldTestamentChanged = { viewModel.includeOldTestament(it) }, + onIncludeNewTestamentChanged = { viewModel.includeNewTestament(it) }, + onIncludeBookmarksChanged = { viewModel.includeBookmarks(it) }, + onIncludeHighlightsChanged = { viewModel.includeHighlights(it) }, + onIncludeNotesChanged = { viewModel.includeNotes(it) }, + onQueryTextListener = object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + searchRecentSuggestions.saveRecentQuery(query, null) + viewModel.search(query, false) + viewBinding.toolbar.hideKeyboard() + + // so that the system can close the search suggestion + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + viewModel.search(newText, true) + return true + } + }, + clearHistory = { lifecycleScope.launch(Dispatchers.IO) { searchRecentSuggestions.clearHistory() } } ) // It's possible that the system has no SearchManager available, and on some devices getSearchableInfo() could return null. // See https://console.firebase.google.com/u/0/project/joshua-production/crashlytics/app/android:me.xizzhu.android.joshua/issues/45465ea5dc4f7722c6ce6b8889196249?time=last-seven-days&type=all (applicationContext.getSystemService(Context.SEARCH_SERVICE) as? SearchManager) - ?.getSearchableInfo(componentName)?.let { viewBinding.toolbar.setSearchableInfo(it) } + ?.getSearchableInfo(componentName)?.let { viewBinding.toolbar.setSearchableInfo(it) } } - override fun inflateViewBinding(): ActivitySearchBinding = ActivitySearchBinding.inflate(layoutInflater) - - override fun viewModel(): SearchViewModel = searchViewModel - override fun openVerse(verseToOpen: VerseIndex) { - searchViewModel.saveCurrentVerseIndex(verseToOpen) - .onSuccess { navigator.navigate(this, Navigator.SCREEN_READING) } - .onFailure { dialog(true, R.string.dialog_title_error, R.string.dialog_message_failed_to_select_verse, { _, _ -> openVerse(verseToOpen) }) } - .launchIn(lifecycleScope) + viewModel.openVerse(verseToOpen) } override fun showPreview(verseIndex: VerseIndex) { - searchViewModel.loadVersesForPreview(verseIndex) - .onSuccess { preview -> listDialog(preview.title, preview.settings, preview.items, preview.currentPosition) } - .onFailure { openVerse(verseIndex) } // Very unlikely to fail, so just falls back to open the verse. - .launchIn(lifecycleScope) + viewModel.loadPreview(verseIndex) } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt index 9d067de2..bd2e6ee1 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/search/SearchViewModel.kt @@ -17,14 +17,13 @@ package me.xizzhu.android.joshua.search import android.app.Application -import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach @@ -37,14 +36,14 @@ import me.xizzhu.android.joshua.core.Highlight import me.xizzhu.android.joshua.core.Note import me.xizzhu.android.joshua.core.SearchConfiguration import me.xizzhu.android.joshua.core.SearchManager +import me.xizzhu.android.joshua.core.Settings import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.Verse import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel -import me.xizzhu.android.joshua.infra.onFailure -import me.xizzhu.android.joshua.infra.viewData +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider +import me.xizzhu.android.joshua.infra.BaseViewModelV2 import me.xizzhu.android.joshua.preview.PreviewViewData -import me.xizzhu.android.joshua.preview.loadPreview +import me.xizzhu.android.joshua.preview.loadPreviewV2 import me.xizzhu.android.joshua.preview.nextNonEmpty import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem @@ -52,51 +51,82 @@ import me.xizzhu.android.joshua.utils.firstNotEmpty import me.xizzhu.android.logger.Log import javax.inject.Inject -class SearchConfigurationViewData(val searchConfig: SearchConfiguration) - -class SearchResultViewData(val items: List, val query: String, val instanceSearch: Boolean, val toast: String) - @HiltViewModel class SearchViewModel @Inject constructor( - private val bibleReadingManager: BibleReadingManager, - private val searchManager: SearchManager, - settingsManager: SettingsManager, - application: Application -) : BaseViewModel(settingsManager, application) { - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal class SearchRequest(val query: String, val instanceSearch: Boolean) + private val bibleReadingManager: BibleReadingManager, + private val searchManager: SearchManager, + private val settingsManager: SettingsManager, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val application: Application +) : BaseViewModelV2( + initialViewState = ViewState( + settings = null, + loading = false, + searchConfig = null, + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null, + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + } - private val searchConfig: MutableStateFlow> = MutableStateFlow(ViewData.Loading()) + data class ViewState( + val settings: Settings?, + val loading: Boolean, + val searchConfig: SearchConfiguration?, + val instantSearch: Boolean, + val items: List, + val preview: PreviewViewData?, + val toast: String?, + val error: Error?, + ) { + sealed class Error { + data class PreviewLoadingError(val verseToPreview: VerseIndex) : Error() + object SearchConfigUpdatingError : Error() + data class VerseOpeningError(val verseToOpen: VerseIndex) : Error() + object VerseSearchingError : Error() + } + } + + private class SearchRequest(val query: String, val instanceSearch: Boolean) - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal val searchRequest: MutableStateFlow = MutableStateFlow(null) - private val searchResult: MutableStateFlow?> = MutableStateFlow(null) + private val searchRequest: MutableStateFlow = MutableStateFlow(null) init { + settingsManager.settings().onEach { settings -> updateViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + searchManager.configuration() - .onEach { - searchConfig.value = ViewData.Success(SearchConfigurationViewData(it)) - retrySearch() - } - .launchIn(viewModelScope) + .onEach { searchConfig -> + updateViewState { it.copy(searchConfig = searchConfig) } + retrySearch() + } + .launchIn(viewModelScope) searchRequest.filterNotNull() - .debounce(250L) - .distinctUntilChangedBy { it.query } - .mapLatest { it } - .onEach { doSearch(it.query, it.instanceSearch) } - .launchIn(viewModelScope) + .debounce(250L) + .distinctUntilChangedBy { it.query } + .mapLatest { it } + .onEach { doSearch(it.query, it.instanceSearch) } + .launchIn(viewModelScope) } - fun searchConfig(): Flow> = searchConfig - fun includeOldTestament(include: Boolean) { updateSearchConfig { it.copy(includeOldTestament = include) } } - private inline fun updateSearchConfig(op: (SearchConfiguration) -> SearchConfiguration) { - (searchConfig.value as? ViewData.Success)?.data?.searchConfig?.let { current -> - op(current).takeIf { it != current }?.let { searchManager.saveConfiguration(it) } + private fun updateSearchConfig(op: (SearchConfiguration) -> SearchConfiguration) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + val current = searchManager.configuration().first() + op(current).takeIf { it != current }?.let { searchManager.saveConfiguration(it) } + }.onFailure { e -> + Log.e(tag, "Error occurred which updating search config", e) + updateViewState { it.copy(error = ViewState.Error.SearchConfigUpdatingError) } + } } } @@ -116,8 +146,6 @@ class SearchViewModel @Inject constructor( updateSearchConfig { it.copy(includeNotes = include) } } - fun searchResult(): Flow> = searchResult.filterNotNull() - fun search(query: String, instanceSearch: Boolean) { searchRequest.value = SearchRequest(query, instanceSearch) } @@ -126,60 +154,66 @@ class SearchViewModel @Inject constructor( searchRequest.value?.let { request -> doSearch(request.query, request.instanceSearch) } } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun doSearch(query: String, instanceSearch: Boolean) { - if (query.length < 2) { - searchResult.value = ViewData.Success(SearchResultViewData(emptyList(), query, instanceSearch, "")) + private fun doSearch(query: String, instantSearch: Boolean) { + if (query.isBlank()) { + updateViewState { it.copy(items = emptyList()) } return } - viewModelScope.launch { - try { - if (!instanceSearch) { - searchResult.value = ViewData.Loading() + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + if (!instantSearch) { + updateViewState { it.copy(loading = true, items = emptyList()) } } val currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty() - searchResult.value = searchManager.search(query).let { searchResult -> - ViewData.Success(SearchResultViewData( - items = buildSearchResultItems( - query = query, - verses = searchResult.verses, - bookmarks = searchResult.bookmarks, - highlights = searchResult.highlights, - notes = searchResult.notes, - bookNames = bibleReadingManager.readBookNames(currentTranslation), - bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) - ), - query = query, - instanceSearch = instanceSearch, - toast = application.getString(R.string.toast_search_result, - searchResult.verses.size + searchResult.bookmarks.size + searchResult.highlights.size + searchResult.notes.size) - )) + val searchResult = searchManager.search(query) + val items = buildSearchResultItems( + query = query, + verses = searchResult.verses, + bookmarks = searchResult.bookmarks, + highlights = searchResult.highlights, + notes = searchResult.notes, + bookNames = bibleReadingManager.readBookNames(currentTranslation), + bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) + ) + val toast = if (instantSearch) { + null + } else { + application.getString(R.string.toast_search_result, + searchResult.verses.size + searchResult.bookmarks.size + searchResult.highlights.size + searchResult.notes.size) + } + updateViewState { current -> + current.copy( + loading = false, + instantSearch = instantSearch, + items = items, + toast = toast, + ) } - } catch (e: Exception) { - Log.e(tag, "Error occurred which searching, query=$query instanceSearch=$instanceSearch, searchConfig=$searchConfig", e) - searchResult.value = ViewData.Failure(e) + }.onFailure { e -> + Log.e(tag, "Error occurred which searching, query=$query instantSearch=$instantSearch", e) + updateViewState { it.copy(loading = false, error = ViewState.Error.VerseSearchingError) } } } } private fun buildSearchResultItems( - query: String, verses: List, bookmarks: List>, highlights: List>, - notes: List>, bookNames: List, bookShortNames: List + query: String, verses: List, bookmarks: List>, highlights: List>, + notes: List>, bookNames: List, bookShortNames: List ): List { val items = ArrayList( - (if (notes.isEmpty()) 0 else notes.size + 1) - + (if (bookmarks.isEmpty()) 0 else bookmarks.size + 1) - + (if (highlights.isEmpty()) 0 else highlights.size + 1) - + verses.size + Bible.BOOK_COUNT + (if (notes.isEmpty()) 0 else notes.size + 1) + + (if (bookmarks.isEmpty()) 0 else bookmarks.size + 1) + + (if (highlights.isEmpty()) 0 else highlights.size + 1) + + verses.size + Bible.BOOK_COUNT ) if (notes.isNotEmpty()) { items.add(TitleItem(application.getString(R.string.title_notes), false)) notes.forEach { note -> items.add(SearchNoteItem( - note.first.verseIndex, bookShortNames[note.first.verseIndex.bookIndex], note.second.text.text, note.first.note, query + note.first.verseIndex, bookShortNames[note.first.verseIndex.bookIndex], note.second.text.text, note.first.note, query )) } } @@ -188,7 +222,7 @@ class SearchViewModel @Inject constructor( items.add(TitleItem(application.getString(R.string.title_bookmarks), false)) bookmarks.forEach { bookmark -> items.add(SearchVerseItem(bookmark.first.verseIndex, bookShortNames[bookmark.first.verseIndex.bookIndex], - bookmark.second.text.text, query, Highlight.COLOR_NONE)) + bookmark.second.text.text, query, Highlight.COLOR_NONE)) } } @@ -196,7 +230,7 @@ class SearchViewModel @Inject constructor( items.add(TitleItem(application.getString(R.string.title_highlights), false)) highlights.forEach { highlight -> items.add(SearchVerseItem(highlight.first.verseIndex, bookShortNames[highlight.first.verseIndex.bookIndex], - highlight.second.text.text, query, highlight.first.color)) + highlight.second.text.text, query, highlight.first.color)) } } @@ -213,16 +247,31 @@ class SearchViewModel @Inject constructor( return items } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun openVerse(verseToOpen: VerseIndex) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + bibleReadingManager.saveCurrentVerseIndex(verseToOpen) + emitViewAction(ViewAction.OpenReadingScreen) + }.onFailure { e -> + Log.e(tag, "Failed to save current verse", e) + updateViewState { it.copy(error = ViewState.Error.VerseOpeningError(verseToOpen)) } + } + } + } - fun loadVersesForPreview(verseIndex: VerseIndex): Flow> = - loadPreview(bibleReadingManager, settingsManager, verseIndex, ::toSearchVersePreviewItems) - .onFailure { Log.e(tag, "Failed to load verses for preview", it) } + fun loadPreview(verseToPreview: VerseIndex) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + val preview = loadPreviewV2(bibleReadingManager, settingsManager, verseToPreview, ::toSearchVersePreviewItems) + updateViewState { it.copy(preview = preview) } + }.onFailure { e -> + Log.e(tag, "Failed to load verses for preview", e) + updateViewState { it.copy(error = ViewState.Error.PreviewLoadingError(verseToPreview)) } + } + } + } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun toSearchVersePreviewItems(verses: List): List { + private fun toSearchVersePreviewItems(verses: List): List { val items = ArrayList(verses.size) val query = searchRequest.value?.query ?: "" @@ -240,4 +289,16 @@ class SearchViewModel @Inject constructor( return items } + + fun markPreviewAsClosed() { + updateViewState { it.copy(preview = null) } + } + + fun markToastAsShown() { + updateViewState { it.copy(toast = null) } + } + + fun markErrorAsShown(error: ViewState.Error) { + updateViewState { current -> if (current.error == error) current.copy(error = null) else null } + } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt index 42a968c4..266f0e84 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/search/SearchViewModelTest.kt @@ -21,12 +21,13 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.* -import me.xizzhu.android.joshua.infra.BaseViewModel import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents import me.xizzhu.android.joshua.ui.recyclerview.TitleItem @@ -47,426 +48,746 @@ class SearchViewModelTest : BaseUnitTest() { override fun setup() { super.setup() - bibleReadingManager = mockk() - coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) - coEvery { bibleReadingManager.readBookNames(MockContents.kjvShortName) } returns MockContents.kjvBookNames - coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames - coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, any()) } returns emptyMap() + bibleReadingManager = mockk().apply { + every { currentTranslation() } returns flowOf(MockContents.kjvShortName) + coEvery { readBookNames(MockContents.kjvShortName) } returns MockContents.kjvBookNames + coEvery { readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames + coEvery { readVerses(MockContents.kjvShortName, any()) } returns emptyMap() + } + searchManager = mockk().apply { + every { configuration() } returns flowOf(SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + )) + coEvery { search(any()) } returns SearchResult(emptyList(), emptyList(), emptyList(), emptyList()) + } + settingsManager = mockk().apply { + every { settings() } returns emptyFlow() + } + application = mockk().apply { + every { getString(R.string.title_bookmarks) } returns "Bookmarks" + every { getString(R.string.title_highlights) } returns "Highlights" + every { getString(R.string.title_notes) } returns "Notes" + every { getString(R.string.toast_search_result, any()) } answers { "${(it.invocation.args[1] as Array)[0]} result(s) found." } + } + + searchViewModel = SearchViewModel(bibleReadingManager, searchManager, settingsManager, testCoroutineDispatcherProvider, application) + } - searchManager = mockk() - every { searchManager.configuration() } returns flowOf(SearchConfiguration(true, true, true, true, true)) - coEvery { searchManager.search(any()) } returns SearchResult(emptyList(), emptyList(), emptyList(), emptyList()) + @Test + fun `test observing settings`() = runTest { + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) - settingsManager = mockk() + searchViewModel = SearchViewModel(bibleReadingManager, searchManager, settingsManager, testCoroutineDispatcherProvider, application) - application = mockk() - every { application.getString(R.string.title_bookmarks) } returns "Bookmarks" - every { application.getString(R.string.title_highlights) } returns "Highlights" - every { application.getString(R.string.title_notes) } returns "Notes" - every { application.getString(R.string.toast_search_result, any()) } answers { "${(it.invocation.args[1] as Array)[0]} result(s) found." } + assertEquals( + SearchViewModel.ViewState( + settings = Settings.DEFAULT, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) + } + + @Test + fun `test includeOldTestament(), with exception`() = runTest { + coEvery { searchManager.configuration() } throws RuntimeException("random exception") - searchViewModel = SearchViewModel(bibleReadingManager, searchManager, settingsManager, application) + searchViewModel.includeOldTestament(false) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = SearchViewModel.ViewState.Error.SearchConfigUpdatingError + ), + searchViewModel.viewState().first() + ) + + searchViewModel.markErrorAsShown(SearchViewModel.ViewState.Error.VerseSearchingError) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = SearchViewModel.ViewState.Error.SearchConfigUpdatingError + ), + searchViewModel.viewState().first() + ) + + searchViewModel.markErrorAsShown(SearchViewModel.ViewState.Error.SearchConfigUpdatingError) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) } @Test - fun `test includeOldTestament`() = runTest { + fun `test includeOldTestament()`() = runTest { every { searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = false, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + includeOldTestament = false, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true )) } returns Unit searchViewModel.includeOldTestament(false) searchViewModel.includeOldTestament(true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) verify(exactly = 1) { searchManager.saveConfiguration(any()) } } @Test - fun `test includeNewTestament`() = runTest { + fun `test includeNewTestament()`() = runTest { every { searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = false, includeBookmarks = true, includeHighlights = true, includeNotes = true + includeOldTestament = true, includeNewTestament = false, includeBookmarks = true, includeHighlights = true, includeNotes = true )) } returns Unit searchViewModel.includeNewTestament(false) searchViewModel.includeNewTestament(true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) verify(exactly = 1) { searchManager.saveConfiguration(any()) } } @Test - fun `test includeBookmarks`() = runTest { + fun `test includeBookmarks()`() = runTest { every { searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = true, includeBookmarks = false, includeHighlights = true, includeNotes = true + includeOldTestament = true, includeNewTestament = true, includeBookmarks = false, includeHighlights = true, includeNotes = true )) } returns Unit searchViewModel.includeBookmarks(false) searchViewModel.includeBookmarks(true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) verify(exactly = 1) { searchManager.saveConfiguration(any()) } } @Test - fun `test includeHighlights`() = runTest { + fun `test includeHighlights()`() = runTest { every { searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = false, includeNotes = true + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = false, includeNotes = true )) } returns Unit searchViewModel.includeHighlights(false) searchViewModel.includeHighlights(true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) verify(exactly = 1) { searchManager.saveConfiguration(any()) } } @Test - fun `test includeNotes`() = runTest { + fun `test includeNotes()`() = runTest { every { searchManager.saveConfiguration(SearchConfiguration( - includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = false + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = false )) } returns Unit searchViewModel.includeNotes(false) searchViewModel.includeNotes(true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) verify(exactly = 1) { searchManager.saveConfiguration(any()) } } @Test - fun `test search() with empty query`() = runTest { + fun `test search(), with empty query`() = runTest { searchViewModel.search("", true) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertTrue(actual.data.items.isEmpty()) - assertTrue(actual.data.query.isEmpty()) - assertTrue(actual.data.instanceSearch) - assertTrue(actual.data.toast.isEmpty()) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) } @Test - fun `test search() with one-character query`() = runTest { - searchViewModel.search("1", true) - delay(1000L) + fun `test search(), with exception`() = runTest { + every { bibleReadingManager.currentTranslation() } throws RuntimeException("random error") + + searchViewModel.search("query", instanceSearch = false) + searchViewModel.retrySearch() - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertTrue(actual.data.items.isEmpty()) - assertEquals("1", actual.data.query) - assertTrue(actual.data.instanceSearch) - assertTrue(actual.data.toast.isEmpty()) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = SearchViewModel.ViewState.Error.VerseSearchingError + ), + searchViewModel.viewState().first() + ) } @Test - fun `test search() with empty result`() = runTest { - searchViewModel.search("query", false) + fun `test search(), with empty result`() = runTest { + searchViewModel.search("query", instanceSearch = false) delay(1000L) - searchViewModel.searchResult().first().assertSuccessEmpty("query", false) - } + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = "0 result(s) found.", + error = null + ), + searchViewModel.viewState().first() + ) - private fun BaseViewModel.ViewData.assertSuccessEmpty(query: String, instanceSearch: Boolean) { - assertTrue(this is BaseViewModel.ViewData.Success) - assertTrue(data.items.isEmpty()) - assertEquals(query, data.query) - assertEquals(instanceSearch, data.instanceSearch) - assertEquals("0 result(s) found.", data.toast) + searchViewModel.markToastAsShown() + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) } @Test - fun `test calling search() multiple times`() = runTest { + fun `test search(), called multiple times`() = runTest { coEvery { searchManager.search("not used") } returns SearchResult(listOf(MockContents.kjvVerses[1]), emptyList(), emptyList(), emptyList()) coEvery { searchManager.search("query") } returns SearchResult(listOf(MockContents.kjvVerses[0]), emptyList(), emptyList(), emptyList()) - searchViewModel.search("invalid", true) + searchViewModel.search("invalid", instanceSearch = true) delay(1000L) - searchViewModel.searchResult().first().assertSuccessEmpty("invalid", true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = true, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) searchViewModel.search("not used", false) delay(100L) // delay is not long enough, so the search should NOT be executed - searchViewModel.searchResult().first().assertSuccessEmpty("invalid", true) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = true, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) - searchViewModel.search("query", false) + searchViewModel.search("query", instanceSearch = false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - - assertEquals(2, actual.data.items.size) - assertEquals("Genesis", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("1 result(s) found.", actual.data.toast) + assertFalse(actual.instantSearch) + assertEquals(2, actual.items.size) + assertEquals("Genesis", (actual.items[0] as TitleItem).title.toString()) + assertEquals( + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[1] as SearchVerseItem).textForDisplay.toString() + ) + assertNull(actual.preview) + assertEquals("1 result(s) found.", actual.toast) + assertNull(actual.error) } @Test - fun `test search() with verses only`() = runTest { + fun `test search(), with verses only`() = runTest { coEvery { searchManager.search("query") } returns SearchResult( - listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2], MockContents.kjvExtraVerses[0], MockContents.kjvExtraVerses[1]), - emptyList(), - emptyList(), - emptyList() + listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2], MockContents.kjvExtraVerses[0], MockContents.kjvExtraVerses[1]), + emptyList(), + emptyList(), + emptyList() ) - searchViewModel.search("query", false) + searchViewModel.search("query", instanceSearch = true) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - - assertEquals(6, actual.data.items.size) - assertEquals("Genesis", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig ) + assertTrue(actual.instantSearch) + assertEquals(6, actual.items.size) + assertEquals("Genesis", (actual.items[0] as TitleItem).title.toString()) assertEquals( - "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[2] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[1] as SearchVerseItem).textForDisplay.toString() ) assertEquals( - "Gen. 10:10\nAnd the beginning of his kingdom was Babel, and Erech, and Accad, and Calneh, in the land of Shinar.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:3\nAnd God said, Let there be light: and there was light.", + (actual.items[2] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Exodus", (actual.data.items[4] as TitleItem).title.toString()) assertEquals( - "Ex. 23:19\nThe first of the firstfruits of thy land thou shalt bring into the house of the LORD thy God. Thou shalt not seethe a kid in his mother’s milk.", - (actual.data.items[5] as SearchVerseItem).textForDisplay.toString() + "Gen. 10:10\nAnd the beginning of his kingdom was Babel, and Erech, and Accad, and Calneh, in the land of Shinar.", + (actual.items[3] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("4 result(s) found.", actual.data.toast) + assertEquals("Exodus", (actual.items[4] as TitleItem).title.toString()) + assertEquals( + "Ex. 23:19\nThe first of the firstfruits of thy land thou shalt bring into the house of the LORD thy God. Thou shalt not seethe a kid in his mother’s milk.", + (actual.items[5] as SearchVerseItem).textForDisplay.toString() + ) + assertNull(actual.preview) + assertNull(actual.toast) + assertNull(actual.error) } @Test - fun `test search() with verses and bookmarks`() = runTest { + fun `test search(), with verses and bookmarks`() = runTest { coEvery { searchManager.search("query") } returns SearchResult( - listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), - listOf(Pair(Bookmark(VerseIndex(0, 0, 0), 12345L), MockContents.kjvVerses[0])), - emptyList(), - emptyList() + listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), + listOf(Pair(Bookmark(VerseIndex(0, 0, 0), 12345L), MockContents.kjvVerses[0])), + emptyList(), + emptyList() ) - searchViewModel.search("query", false) + searchViewModel.search("query", instanceSearch = false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - - assertEquals(5, actual.data.items.size) - assertEquals("Bookmarks", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig ) - assertEquals("Genesis", (actual.data.items[2] as TitleItem).title.toString()) + assertFalse(actual.instantSearch) + assertEquals(5, actual.items.size) + assertEquals("Bookmarks", (actual.items[0] as TitleItem).title.toString()) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[1] as SearchVerseItem).textForDisplay.toString() ) + assertEquals("Genesis", (actual.items[2] as TitleItem).title.toString()) assertEquals( - "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[4] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[3] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("3 result(s) found.", actual.data.toast) + assertEquals( + "Gen. 1:3\nAnd God said, Let there be light: and there was light.", + (actual.items[4] as SearchVerseItem).textForDisplay.toString() + ) + assertNull(actual.preview) + assertEquals("3 result(s) found.", actual.toast) + assertNull(actual.error) } @Test - fun `test search() with verses and highlights`() = runTest { + fun `test search(), with verses and highlights`() = runTest { coEvery { searchManager.search("query") } returns SearchResult( - listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), - emptyList(), - listOf(Pair(Highlight(VerseIndex(0, 0, 0), Highlight.COLOR_PINK, 12345L), MockContents.kjvVerses[0])), - emptyList() + listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), + emptyList(), + listOf(Pair(Highlight(VerseIndex(0, 0, 0), Highlight.COLOR_PINK, 12345L), MockContents.kjvVerses[0])), + emptyList() ) - searchViewModel.search("query", false) + searchViewModel.search("query", instanceSearch = false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - - assertEquals(5, actual.data.items.size) - assertEquals("Highlights", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[1] as SearchVerseItem).textForDisplay.toString() + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig ) - assertEquals("Genesis", (actual.data.items[2] as TitleItem).title.toString()) + assertFalse(actual.instantSearch) + assertEquals(5, actual.items.size) + assertEquals("Highlights", (actual.items[0] as TitleItem).title.toString()) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[1] as SearchVerseItem).textForDisplay.toString() ) + assertEquals("Genesis", (actual.items[2] as TitleItem).title.toString()) assertEquals( - "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[4] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[3] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("3 result(s) found.", actual.data.toast) + assertEquals( + "Gen. 1:3\nAnd God said, Let there be light: and there was light.", + (actual.items[4] as SearchVerseItem).textForDisplay.toString() + ) + assertNull(actual.preview) + assertEquals("3 result(s) found.", actual.toast) + assertNull(actual.error) } @Test - fun `test search() with verses and notes`() = runTest { + fun `test search(), with verses and notes`() = runTest { coEvery { searchManager.search("query") } returns SearchResult( - listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), - emptyList(), - emptyList(), - listOf(Pair(Note(VerseIndex(0, 0, 9), "just a note", 12345L), MockContents.kjvVerses[9])) + listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), + emptyList(), + emptyList(), + listOf(Pair(Note(VerseIndex(0, 0, 9), "just a note", 12345L), MockContents.kjvVerses[9])) ) - searchViewModel.search("query", false) + searchViewModel.search("query", instanceSearch = false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - - assertEquals(5, actual.data.items.size) - assertEquals("Notes", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) assertEquals( - "Gen. 1:10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good.", - (actual.data.items[1] as SearchNoteItem).verseForDisplay.toString() + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig ) - assertEquals("just a note", (actual.data.items[1] as SearchNoteItem).noteForDisplay.toString()) - assertEquals("Genesis", (actual.data.items[2] as TitleItem).title.toString()) + assertFalse(actual.instantSearch) + assertEquals(5, actual.items.size) + assertEquals("Notes", (actual.items[0] as TitleItem).title.toString()) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good.", + (actual.items[1] as SearchNoteItem).verseForDisplay.toString() ) + assertEquals("just a note", (actual.items[1] as SearchNoteItem).noteForDisplay.toString()) + assertEquals("Genesis", (actual.items[2] as TitleItem).title.toString()) assertEquals( - "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[4] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[3] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("3 result(s) found.", actual.data.toast) + assertEquals( + "Gen. 1:3\nAnd God said, Let there be light: and there was light.", + (actual.items[4] as SearchVerseItem).textForDisplay.toString() + ) + assertNull(actual.preview) + assertEquals("3 result(s) found.", actual.toast) + assertNull(actual.error) } @Test - fun `test search() with verses, bookmarks, highlights, and notes`() = runTest { + fun `test search(), with verses, bookmarks, highlights, and notes`() = runTest { coEvery { searchManager.search("query") } returns SearchResult( - listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), - listOf(Pair(Bookmark(VerseIndex(0, 0, 0), 12345L), MockContents.kjvVerses[0])), - listOf(Pair(Highlight(VerseIndex(0, 0, 0), Highlight.COLOR_PINK, 12345L), MockContents.kjvVerses[0])), - listOf(Pair(Note(VerseIndex(0, 0, 9), "just a note", 12345L), MockContents.kjvVerses[9])) + listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[2]), + listOf(Pair(Bookmark(VerseIndex(0, 0, 0), 12345L), MockContents.kjvVerses[0])), + listOf(Pair(Highlight(VerseIndex(0, 0, 0), Highlight.COLOR_PINK, 12345L), MockContents.kjvVerses[0])), + listOf(Pair(Note(VerseIndex(0, 0, 9), "just a note", 12345L), MockContents.kjvVerses[9])) ) - searchViewModel.search("query", false) + searchViewModel.search("query", instanceSearch = false) delay(1000L) - val actual = searchViewModel.searchResult().first() - assertTrue(actual is BaseViewModel.ViewData.Success) - - assertEquals(9, actual.data.items.size) - assertEquals("Notes", (actual.data.items[0] as TitleItem).title.toString()) + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) assertEquals( - "Gen. 1:10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good.", - (actual.data.items[1] as SearchNoteItem).verseForDisplay.toString() + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig ) - assertEquals("just a note", (actual.data.items[1] as SearchNoteItem).noteForDisplay.toString()) - assertEquals("Bookmarks", (actual.data.items[2] as TitleItem).title.toString()) + assertFalse(actual.instantSearch) + assertEquals(9, actual.items.size) + assertEquals("Notes", (actual.items[0] as TitleItem).title.toString()) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[3] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:10 And God called the dry land Earth; and the gathering together of the waters called he Seas: and God saw that it was good.", + (actual.items[1] as SearchNoteItem).verseForDisplay.toString() ) - assertEquals("Highlights", (actual.data.items[4] as TitleItem).title.toString()) + assertEquals("just a note", (actual.items[1] as SearchNoteItem).noteForDisplay.toString()) + assertEquals("Bookmarks", (actual.items[2] as TitleItem).title.toString()) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[5] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[3] as SearchVerseItem).textForDisplay.toString() ) - assertEquals("Genesis", (actual.data.items[6] as TitleItem).title.toString()) + assertEquals("Highlights", (actual.items[4] as TitleItem).title.toString()) assertEquals( - "Gen. 1:1\nIn the beginning God created the heaven and the earth.", - (actual.data.items[7] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[5] as SearchVerseItem).textForDisplay.toString() ) + assertEquals("Genesis", (actual.items[6] as TitleItem).title.toString()) assertEquals( - "Gen. 1:3\nAnd God said, Let there be light: and there was light.", - (actual.data.items[8] as SearchVerseItem).textForDisplay.toString() + "Gen. 1:1\nIn the beginning God created the heaven and the earth.", + (actual.items[7] as SearchVerseItem).textForDisplay.toString() ) - - assertEquals("query", actual.data.query) - assertFalse(actual.data.instanceSearch) - assertEquals("5 result(s) found.", actual.data.toast) + assertEquals( + "Gen. 1:3\nAnd God said, Let there be light: and there was light.", + (actual.items[8] as SearchVerseItem).textForDisplay.toString() + ) + assertNull(actual.preview) + assertEquals("5 result(s) found.", actual.toast) + assertNull(actual.error) } @Test - fun `test loadVersesForPreview() with invalid verse index`() = runTest { - val actual = searchViewModel.loadVersesForPreview(VerseIndex.INVALID).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - assertTrue(actual[1] is BaseViewModel.ViewData.Failure) + fun `test openVerse() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + searchViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = SearchViewModel.ViewState.Error.VerseOpeningError(VerseIndex(0, 0, 0)) + ), + searchViewModel.viewState().first() + ) } @Test - fun `test loadVersesForPreview()`() = runTest { - coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) - coEvery { - bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) - } returns listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[1], MockContents.kjvVerses[2]) - coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames - every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit - val actual = searchViewModel.loadVersesForPreview(VerseIndex(0, 0, 1)).toList() - assertEquals(2, actual.size) - assertTrue(actual[0] is BaseViewModel.ViewData.Loading) - - assertEquals(Settings.DEFAULT, (actual[1] as BaseViewModel.ViewData.Success).data.settings) - assertEquals("Gen., 1", (actual[1] as BaseViewModel.ViewData.Success).data.title) - assertEquals(3, (actual[1] as BaseViewModel.ViewData.Success).data.items.size) - assertEquals(1, (actual[1] as BaseViewModel.ViewData.Success).data.currentPosition) - } + val viewAction = async(Dispatchers.Unconfined) { searchViewModel.viewAction().first() } + searchViewModel.openVerse(VerseIndex(0, 0, 0)) - @Test - fun `test toSearchVersePreviewItems() with single verse`() { - searchViewModel.searchRequest.value = SearchViewModel.SearchRequest("God", true) - val actual = with(searchViewModel) { toSearchVersePreviewItems(listOf(MockContents.kjvVerses[0])) } - assertEquals(1, actual.size) - assertEquals("1:1 In the beginning God created the heaven and the earth.", actual[0].textForDisplay.toString()) + assertEquals( + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() + ) + assertEquals(SearchViewModel.ViewAction.OpenReadingScreen, viewAction.await()) } @Test - fun `test toSearchVersePreviewItems() with multiple verses`() { - searchViewModel.searchRequest.value = SearchViewModel.SearchRequest("God", true) - val actual = with(searchViewModel) { toSearchVersePreviewItems(listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[1])) } - assertEquals(2, actual.size) - assertEquals("1:1 In the beginning God created the heaven and the earth.", actual[0].textForDisplay.toString()) + fun `test loadPreview() with invalid verse index`() = runTest { + searchViewModel.loadPreview(VerseIndex.INVALID) + assertEquals( - "1:2 And the earth was without form, and void; and darkness was upon the face of the deep. And the Spirit of God moved upon the face of the waters.", - actual[1].textForDisplay.toString() + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = SearchViewModel.ViewState.Error.PreviewLoadingError(VerseIndex.INVALID) + ), + searchViewModel.viewState().first() ) } @Test - fun `test toSearchVersePreviewItems() with multiple verses but not consecutive`() { - searchViewModel.searchRequest.value = SearchViewModel.SearchRequest("God", true) - val actual = with(searchViewModel) { toSearchVersePreviewItems(listOf(MockContents.msgVerses[0], MockContents.msgVerses[1], MockContents.msgVerses[2])) } - assertEquals(2, actual.size) + fun `test loadPreview()`() = runTest { + coEvery { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) + coEvery { + bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) + } returns listOf(MockContents.kjvVerses[0], MockContents.kjvVerses[1], MockContents.kjvVerses[2]) + coEvery { bibleReadingManager.readBookShortNames(MockContents.kjvShortName) } returns MockContents.kjvBookShortNames + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) + + searchViewModel.loadPreview(VerseIndex(0, 0, 1)) + + val actual = searchViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) + assertEquals( + SearchConfiguration(includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true), + actual.searchConfig + ) + assertFalse(actual.instantSearch) + assertTrue(actual.items.isEmpty()) + assertEquals(Settings.DEFAULT, actual.preview?.settings) + assertEquals("Gen., 1", actual.preview?.title) + assertEquals(3, actual.preview?.items?.size) + assertEquals(MockContents.kjvVerses[0], (actual.preview?.items?.get(0) as SearchVersePreviewItem).verse) + assertEquals( + "1:1 In the beginning God created the heaven and the earth.", + (actual.preview?.items?.get(0) as SearchVersePreviewItem).textForDisplay.toString() + ) + assertEquals(MockContents.kjvVerses[1], (actual.preview?.items?.get(1) as SearchVersePreviewItem).verse) assertEquals( - "1:1-2 First this: God created the Heavens and Earth—all you see, all you don't see. Earth was a soup of nothingness, a bottomless emptiness, an inky blackness. God's Spirit brooded like a bird above the watery abyss.", - actual[0].textForDisplay.toString() + "1:2 And the earth was without form, and void; and darkness was upon the face of the deep. And the Spirit of God moved upon the face of the waters.", + (actual.preview?.items?.get(1) as SearchVersePreviewItem).textForDisplay.toString() ) + assertEquals(MockContents.kjvVerses[2], (actual.preview?.items?.get(2) as SearchVersePreviewItem).verse) + assertEquals( + "1:3 And God said, Let there be light: and there was light.", + (actual.preview?.items?.get(2) as SearchVersePreviewItem).textForDisplay.toString() + ) + assertEquals(1, actual.preview?.currentPosition) + assertNull(actual.toast) + assertNull(actual.error) + + searchViewModel.markPreviewAsClosed() assertEquals( - "1:3 God spoke: \"Light!\"\nAnd light appeared.\nGod saw that light was good\nand separated light from dark.\nGod named the light Day,\nhe named the dark Night.\nIt was evening, it was morning—\nDay One.", - actual[1].textForDisplay.toString() + SearchViewModel.ViewState( + settings = null, + loading = false, + searchConfig = SearchConfiguration( + includeOldTestament = true, includeNewTestament = true, includeBookmarks = true, includeHighlights = true, includeNotes = true + ), + instantSearch = false, + items = emptyList(), + preview = null, + toast = null, + error = null + ), + searchViewModel.viewState().first() ) } }