From f7ebd729477ec19fe5aa7a80fee2e58fab5175ae Mon Sep 17 00:00:00 2001 From: Xizhi Zhu Date: Fri, 25 Nov 2022 11:13:54 +0900 Subject: [PATCH] Refactor AnnotatedVerses Activity / ViewModel to use single view state --- .../me/xizzhu/android/joshua/Injection.kt | 20 +- .../annotated/AnnotatedVersesActivity.kt | 140 +++--- .../annotated/AnnotatedVersesViewModel.kt | 198 +++++--- .../annotated/bookmarks/BookmarksActivity.kt | 34 +- .../annotated/bookmarks/BookmarksModule.kt | 60 --- .../highlights/HighlightsActivity.kt | 29 +- .../annotated/highlights/HighlightsModule.kt | 60 --- .../joshua/annotated/notes/NotesActivity.kt | 27 +- .../joshua/annotated/notes/NotesModule.kt | 60 --- .../CoroutineDispatcherProvider.kt | 2 +- .../joshua/core/provider/TimeProvider.kt | 37 ++ .../android/joshua/infra/BaseActivityV2.kt | 8 +- .../strongnumber/StrongNumberActivity.kt | 30 +- .../strongnumber/StrongNumberViewModel.kt | 10 +- .../annotated/AnnotatedVersesViewModelTest.kt | 427 +++++++++++++----- .../bookmarks/BookmarksViewModelTest.kt | 21 +- .../highlights/HighlightsViewModelTest.kt | 21 +- .../annotated/notes/NotesViewModelTest.kt | 21 +- .../strongnumber/StrongNumberViewModelTest.kt | 12 + .../android/joshua/tests/BaseUnitTest.kt | 2 +- .../android/joshua/tests/TestTimeProvider.kt | 26 ++ 21 files changed, 741 insertions(+), 504 deletions(-) delete mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksModule.kt delete mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsModule.kt delete mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesModule.kt rename app/src/main/kotlin/me/xizzhu/android/joshua/core/{ => provider}/CoroutineDispatcherProvider.kt (96%) create mode 100644 app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/TimeProvider.kt create mode 100644 app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestTimeProvider.kt diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt index d0aed5f5..a778b626 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/Injection.kt @@ -25,6 +25,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import me.xizzhu.android.joshua.core.* +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider +import me.xizzhu.android.joshua.core.provider.DefaultCoroutineDispatcherProvider +import me.xizzhu.android.joshua.core.provider.DefaultTimeProvider +import me.xizzhu.android.joshua.core.provider.TimeProvider import me.xizzhu.android.joshua.core.repository.* import me.xizzhu.android.joshua.core.repository.local.android.* import me.xizzhu.android.joshua.core.repository.local.android.db.AndroidDatabase @@ -43,10 +47,6 @@ object AppModule { @Singleton fun provideApp(application: Application): App = application as App - @Provides - @Singleton - fun provideCoroutineDispatcherProvider(): CoroutineDispatcherProvider = DefaultCoroutineDispatcherProvider() - @Provides @Singleton fun provideAppScope(): CoroutineScope = appScope @@ -124,6 +124,18 @@ object AppModule { TranslationManager(translationRepository) } +@Module +@InstallIn(SingletonComponent::class) +object ProviderModule { + @Provides + @Singleton + fun provideCoroutineDispatcherProvider(): CoroutineDispatcherProvider = DefaultCoroutineDispatcherProvider() + + @Provides + @Singleton + fun provideTimeProvider(): TimeProvider = DefaultTimeProvider() +} + @Module @InstallIn(SingletonComponent::class) object RepositoryModule { diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt index 078941cf..dc160baa 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesActivity.kt @@ -17,11 +17,8 @@ package me.xizzhu.android.joshua.annotated import android.os.Bundle -import android.view.View import androidx.annotation.StringRes -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import androidx.core.view.isVisible import me.xizzhu.android.joshua.Navigator import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.annotated.bookmarks.BookmarkItem @@ -31,67 +28,90 @@ import me.xizzhu.android.joshua.core.Constants import me.xizzhu.android.joshua.core.VerseAnnotation import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.databinding.ActivityAnnotatedBinding -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.preview.VersePreviewItem import me.xizzhu.android.joshua.ui.dialog import me.xizzhu.android.joshua.ui.fadeIn import me.xizzhu.android.joshua.ui.listDialog -import javax.inject.Inject abstract class AnnotatedVersesActivity>( - @StringRes private val toolbarText: Int -) : BaseActivity(), BookmarkItem.Callback, HighlightItem.Callback, NoteItem.Callback, VersePreviewItem.Callback { - @Inject - lateinit var annotatedVersesViewModel: VM - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + @StringRes private val toolbarText: Int +) : BaseActivityV2(), BookmarkItem.Callback, HighlightItem.Callback, NoteItem.Callback, VersePreviewItem.Callback { + override fun inflateViewBinding(): ActivityAnnotatedBinding = ActivityAnnotatedBinding.inflate(layoutInflater) - observeSettings() - observeSortOrder() - observeAnnotatedVerses() - initializeToolbar() + override fun onViewActionEmitted(viewAction: AnnotatedVersesViewModel.ViewAction) = when (viewAction) { + AnnotatedVersesViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING, extrasForOpeningVerse()) } - private fun observeSettings() { - annotatedVersesViewModel.settings().onEach { viewBinding.verseList.setSettings(it) }.launchIn(lifecycleScope) - } + override fun onViewStateUpdated(viewState: AnnotatedVersesViewModel.ViewState) = with(viewBinding) { + viewState.settings?.let { verseList.setSettings(it) } - private fun observeSortOrder() { - annotatedVersesViewModel.sortOrder().onEach { viewBinding.toolbar.setSortOrder(it) }.launchIn(lifecycleScope) - } + toolbar.setSortOrder(viewState.sortOrder) + + if (viewState.loading) { + loadingSpinner.fadeIn() + verseList.isVisible = false + } else { + loadingSpinner.isVisible = false + verseList.fadeIn() + } + + verseList.setItems(viewState.items) + + viewState.preview?.let { preview -> + listDialog( + title = preview.title, + settings = preview.settings, + items = preview.items, + selected = preview.currentPosition, + onDismiss = { viewModel.markPreviewAsClosed() } + ) + } - private fun observeAnnotatedVerses() { - annotatedVersesViewModel.annotatedVerses() - .onEach( - onLoading = { - with(viewBinding) { - loadingSpinner.fadeIn() - verseList.visibility = View.GONE - } - }, - onSuccess = { - with(viewBinding) { - verseList.setItems(it.items) - verseList.fadeIn() - loadingSpinner.visibility = View.GONE - } - }, - onFailure = { - viewBinding.loadingSpinner.visibility = View.GONE - dialog( - false, R.string.dialog_title_error, R.string.dialog_message_failed_to_load_annotated_verses, - { _, _ -> annotatedVersesViewModel.loadAnnotatedVerses() }, { _, _ -> finish() } - ) - } + when (val error = viewState.error) { + is AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError -> { + dialog( + cancelable = false, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_load_annotated_verses, + onPositive = { _, _ -> viewModel.loadAnnotatedVerses() }, + onNegative = { _, _ -> finish() }, + onDismiss = { viewModel.markErrorAsShown(error) } ) - .launchIn(lifecycleScope) + } + is AnnotatedVersesViewModel.ViewState.Error.PreviewLoadingError -> { + viewModel.markErrorAsShown(error) + + // Very unlikely to fail, so just falls back to open the verse. + openVerse(error.verseToPreview) + } + is AnnotatedVersesViewModel.ViewState.Error.SortOrderSavingError -> { + dialog( + cancelable = true, + title = R.string.dialog_title_error, + message = R.string.dialog_message_failed_to_save_sort_order, + onPositive = { _, _ -> saveSortOrder(error.sortOrder) }, + onDismiss = { viewModel.markErrorAsShown(error) } + ) + } + is AnnotatedVersesViewModel.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) } + ) + } + null -> { + // Do nothing + } + } } - private fun initializeToolbar() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewBinding.toolbar) { setTitle(toolbarText) sortOrderUpdated = ::saveSortOrder @@ -99,27 +119,15 @@ abstract class AnnotatedVersesActivity saveSortOrder(sortOrder) }) } - .launchIn(lifecycleScope) + viewModel.saveSortOrder(sortOrder) } - override fun inflateViewBinding(): ActivityAnnotatedBinding = ActivityAnnotatedBinding.inflate(layoutInflater) - - override fun viewModel(): VM = annotatedVersesViewModel - override fun openVerse(verseToOpen: VerseIndex) { - annotatedVersesViewModel.saveCurrentVerseIndex(verseToOpen) - .onSuccess { navigator.navigate(this, Navigator.SCREEN_READING, extrasForOpeningVerse()) } - .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) { - annotatedVersesViewModel.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) } protected open fun extrasForOpeningVerse(): Bundle? = null diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt index ac33b8d5..7cf116e5 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModel.kt @@ -20,102 +20,124 @@ import android.app.Application import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.core.BibleReadingManager import me.xizzhu.android.joshua.core.Constants +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider +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.VerseAnnotation import me.xizzhu.android.joshua.core.VerseAnnotationManager 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.TimeProvider +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.toVersePreviewItems import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TextItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem -import me.xizzhu.android.joshua.utils.currentTimeMillis import me.xizzhu.android.joshua.utils.firstNotEmpty import me.xizzhu.android.logger.Log import java.util.Calendar import kotlin.collections.ArrayList -class AnnotatedVersesViewData(val items: List) - abstract class AnnotatedVersesViewModel( - private val bibleReadingManager: BibleReadingManager, - private val verseAnnotationManager: VerseAnnotationManager, - @StringRes private val noItemText: Int, - settingsManager: SettingsManager, - application: Application -) : BaseViewModel(settingsManager, application) { - private val annotatedVerses: MutableStateFlow?> = MutableStateFlow(null) + private val bibleReadingManager: BibleReadingManager, + private val verseAnnotationManager: VerseAnnotationManager, + @StringRes private val noItemText: Int, + private val settingsManager: SettingsManager, + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, + private val timeProvider: TimeProvider, + private val application: Application +) : BaseViewModelV2( + initialViewState = ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + items = emptyList(), + preview = null, + error = null, + ) +) { + sealed class ViewAction { + object OpenReadingScreen : ViewAction() + } + + data class ViewState( + val settings: Settings?, + val loading: Boolean, + @Constants.SortOrder val sortOrder: Int, + val items: List, + val preview: PreviewViewData?, + val error: Error?, + ) { + sealed class Error { + object AnnotatedVersesLoadingError : Error() + data class PreviewLoadingError(val verseToPreview: VerseIndex) : Error() + data class SortOrderSavingError(@Constants.SortOrder val sortOrder: Int) : Error() + data class VerseOpeningError(val verseToOpen: VerseIndex) : Error() + } + } init { + settingsManager.settings().onEach { settings -> updateViewState { it.copy(settings = settings) } }.launchIn(viewModelScope) + combine( - bibleReadingManager.currentTranslation().filter { it.isNotEmpty() }, - verseAnnotationManager.sortOrder() + bibleReadingManager.currentTranslation().filter { it.isNotEmpty() }, + verseAnnotationManager.sortOrder() ) { currentTranslation, sortOrder -> - try { - annotatedVerses.value = ViewData.Loading() - annotatedVerses.value = ViewData.Success(loadAnnotatedVerses(currentTranslation, sortOrder)) - } catch (e: Exception) { + runCatching { + updateViewState { it.copy(loading = true, sortOrder = sortOrder, items = emptyList()) } + + val items = loadAnnotatedVerses(currentTranslation, sortOrder) + updateViewState { it.copy(loading = false, items = items) } + }.onFailure { e -> Log.e(tag, "Error occurred while loading annotated verses", e) - annotatedVerses.value = ViewData.Failure(e) + updateViewState { it.copy(loading = false, error = ViewState.Error.AnnotatedVersesLoadingError) } } - loadAnnotatedVerses(currentTranslation, sortOrder) - }.launchIn(viewModelScope) + }.flowOn(coroutineDispatcherProvider.default).launchIn(viewModelScope) } - fun sortOrder(): Flow = verseAnnotationManager.sortOrder() - - fun saveSortOrder(@Constants.SortOrder sortOrder: Int): Flow> = viewData { - verseAnnotationManager.saveSortOrder(sortOrder) - }.onFailure { Log.e(tag, "Failed to save sort order", it) } - - fun annotatedVerses(): Flow> = annotatedVerses.filterNotNull() - fun loadAnnotatedVerses() { - viewModelScope.launch { - try { - annotatedVerses.value = ViewData.Loading() - annotatedVerses.value = ViewData.Success(loadAnnotatedVerses( - currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty(), - sortOrder = verseAnnotationManager.sortOrder().first() - )) - } catch (e: Exception) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + updateViewState { it.copy(loading = true, items = emptyList()) } + + val sortOrder = verseAnnotationManager.sortOrder().first() + val items = loadAnnotatedVerses( + currentTranslation = bibleReadingManager.currentTranslation().firstNotEmpty(), + sortOrder = sortOrder + ) + updateViewState { it.copy(loading = false, sortOrder = sortOrder, items = items) } + }.onFailure { e -> Log.e(tag, "Error occurred while loading annotated verses", e) - annotatedVerses.value = ViewData.Failure(e) + updateViewState { it.copy(loading = false, error = ViewState.Error.AnnotatedVersesLoadingError) } } } } - private suspend fun loadAnnotatedVerses(currentTranslation: String, @Constants.SortOrder sortOrder: Int): AnnotatedVersesViewData { + private suspend fun loadAnnotatedVerses(currentTranslation: String, @Constants.SortOrder sortOrder: Int): List { val annotations = verseAnnotationManager.read(sortOrder) val verses = bibleReadingManager.readVerses(currentTranslation, annotations.map { it.verseIndex }) - return AnnotatedVersesViewData( - items = buildAnnotatedVersesItems( - sortOrder = sortOrder, - verses = annotations.mapNotNull { annotation -> verses[annotation.verseIndex]?.let { Pair(annotation, it) } }, - bookNames = bibleReadingManager.readBookNames(currentTranslation), - bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) - ) + return buildAnnotatedVersesItems( + sortOrder = sortOrder, + verses = annotations.mapNotNull { annotation -> verses[annotation.verseIndex]?.let { Pair(annotation, it) } }, + bookNames = bibleReadingManager.readBookNames(currentTranslation), + bookShortNames = bibleReadingManager.readBookShortNames(currentTranslation) ) } private fun buildAnnotatedVersesItems( - @Constants.SortOrder sortOrder: Int, verses: List>, bookNames: List, bookShortNames: List + @Constants.SortOrder sortOrder: Int, verses: List>, bookNames: List, bookShortNames: List ): List = if (verses.isEmpty()) { listOf(TextItem(application.getString(noItemText))) } else { @@ -127,7 +149,7 @@ abstract class AnnotatedVersesViewModel( } private fun buildItemsByDate(verses: List>, bookNames: List, bookShortNames: List): List { - val calendar = Calendar.getInstance() + val calendar = timeProvider.calendar var previousYear = -1 var previousDayOfYear = -1 @@ -144,34 +166,33 @@ abstract class AnnotatedVersesViewModel( } items.add(buildBaseItem( - verseAnnotation, bookNames[verseAnnotation.verseIndex.bookIndex], - bookShortNames[verseAnnotation.verseIndex.bookIndex], verse.text.text, Constants.SORT_BY_DATE + verseAnnotation, bookNames[verseAnnotation.verseIndex.bookIndex], + bookShortNames[verseAnnotation.verseIndex.bookIndex], verse.text.text, Constants.SORT_BY_DATE )) } return items } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun formatDate(calendar: Calendar, timestamp: Long): String { - calendar.timeInMillis = currentTimeMillis() + private fun formatDate(calendar: Calendar, timestamp: Long): String { + calendar.timeInMillis = timeProvider.currentTimeMillis val currentYear = calendar.get(Calendar.YEAR) calendar.timeInMillis = timestamp val year = calendar.get(Calendar.YEAR) return if (year == currentYear) { application.getString(R.string.text_date_without_year, - application.resources.getStringArray(R.array.text_months)[calendar.get(Calendar.MONTH)], - calendar.get(Calendar.DATE)) + application.resources.getStringArray(R.array.text_months)[calendar.get(Calendar.MONTH)], + calendar.get(Calendar.DATE)) } else { application.getString(R.string.text_date, - application.resources.getStringArray(R.array.text_months)[calendar.get(Calendar.MONTH)], - calendar.get(Calendar.DATE), year) + application.resources.getStringArray(R.array.text_months)[calendar.get(Calendar.MONTH)], + calendar.get(Calendar.DATE), year) } } @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) internal abstract fun buildBaseItem( - annotatedVerse: V, bookName: String, bookShortName: String, verseText: String, @Constants.SortOrder sortOrder: Int + annotatedVerse: V, bookName: String, bookShortName: String, verseText: String, @Constants.SortOrder sortOrder: Int ): BaseItem private fun buildItemsByBook(verses: List>, bookNames: List, bookShortNames: List): List { @@ -185,18 +206,53 @@ abstract class AnnotatedVersesViewModel( } items.add(buildBaseItem( - verseAnnotation, bookNames[verseAnnotation.verseIndex.bookIndex], - bookShortNames[verseAnnotation.verseIndex.bookIndex], verse.text.text, Constants.SORT_BY_BOOK + verseAnnotation, bookNames[verseAnnotation.verseIndex.bookIndex], + bookShortNames[verseAnnotation.verseIndex.bookIndex], verse.text.text, Constants.SORT_BY_BOOK )) } return items } - fun saveCurrentVerseIndex(verseToOpen: VerseIndex): Flow> = viewData { - bibleReadingManager.saveCurrentVerseIndex(verseToOpen) - }.onFailure { Log.e(tag, "Failed to save current verse", it) } + fun saveSortOrder(@Constants.SortOrder sortOrder: Int) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + verseAnnotationManager.saveSortOrder(sortOrder) + }.onFailure { e -> + Log.e(tag, "Failed to save sort order", e) + updateViewState { it.copy(error = ViewState.Error.SortOrderSavingError(sortOrder)) } + } + } + } - fun loadVersesForPreview(verseIndex: VerseIndex): Flow> = - loadPreview(bibleReadingManager, settingsManager, verseIndex, ::toVersePreviewItems) - .onFailure { Log.e(tag, "Failed to load verses for preview", 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 loadPreview(verseToPreview: VerseIndex) { + viewModelScope.launch(coroutineDispatcherProvider.default) { + runCatching { + val preview = loadPreviewV2(bibleReadingManager, settingsManager, verseToPreview, ::toVersePreviewItems) + 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)) } + } + } + } + + fun markPreviewAsClosed() { + updateViewState { it.copy(preview = null) } + } + + fun markErrorAsShown(error: ViewState.Error) { + updateViewState { current -> if (current.error == error) current.copy(error = null) else null } + } } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksActivity.kt index b4ca54ca..e4eff72c 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksActivity.kt @@ -17,25 +17,43 @@ package me.xizzhu.android.joshua.annotated.bookmarks import android.app.Application +import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.annotated.AnnotatedVersesActivity import me.xizzhu.android.joshua.annotated.AnnotatedVersesViewModel import me.xizzhu.android.joshua.core.BibleReadingManager import me.xizzhu.android.joshua.core.Bookmark +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.VerseAnnotationManager +import me.xizzhu.android.joshua.core.provider.TimeProvider import me.xizzhu.android.joshua.ui.recyclerview.BaseItem +import javax.inject.Inject -class BookmarksViewModel( - bibleReadingManager: BibleReadingManager, - bookmarksManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application -) : AnnotatedVersesViewModel(bibleReadingManager, bookmarksManager, R.string.text_no_bookmarks, settingsManager, application) { +@HiltViewModel +class BookmarksViewModel @Inject constructor( + bibleReadingManager: BibleReadingManager, + bookmarksManager: VerseAnnotationManager, + settingsManager: SettingsManager, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + timeProvider: TimeProvider, + application: Application +) : AnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = bookmarksManager, + noItemText = R.string.text_no_bookmarks, + settingsManager = settingsManager, + coroutineDispatcherProvider = coroutineDispatcherProvider, + timeProvider = timeProvider, + application = application +) { override fun buildBaseItem(annotatedVerse: Bookmark, bookName: String, bookShortName: String, verseText: String, sortOrder: Int): BaseItem = - BookmarkItem(annotatedVerse.verseIndex, bookName, bookShortName, verseText, sortOrder) + BookmarkItem(annotatedVerse.verseIndex, bookName, bookShortName, verseText, sortOrder) } @AndroidEntryPoint -class BookmarksActivity : AnnotatedVersesActivity(R.string.title_bookmarks) +class BookmarksActivity : AnnotatedVersesActivity(R.string.title_bookmarks) { + override val viewModel: BookmarksViewModel by viewModels() +} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksModule.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksModule.kt deleted file mode 100644 index 1ee924c1..00000000 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksModule.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2022 Xizhi Zhu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package me.xizzhu.android.joshua.annotated.bookmarks - -import android.app.Activity -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Bookmark -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.VerseAnnotationManager - -@Module -@InstallIn(ActivityComponent::class) -object BookmarksModule { - @Provides - fun provideBookmarksActivity(activity: Activity): BookmarksActivity = activity as BookmarksActivity - - @ActivityScoped - @Provides - fun provideBookmarksViewModel( - bookmarksActivity: BookmarksActivity, - bibleReadingManager: BibleReadingManager, - bookmarksManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application - ): BookmarksViewModel { - val factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(BookmarksViewModel::class.java)) { - return BookmarksViewModel(bibleReadingManager, bookmarksManager, settingsManager, application) as T - } - - throw IllegalArgumentException("Unsupported model class - $modelClass") - - } - } - return ViewModelProvider(bookmarksActivity, factory)[BookmarksViewModel::class.java] - } -} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsActivity.kt index f99e01ad..42adf2f9 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsActivity.kt @@ -17,28 +17,43 @@ package me.xizzhu.android.joshua.annotated.highlights import android.app.Application +import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.annotated.AnnotatedVersesActivity import me.xizzhu.android.joshua.annotated.AnnotatedVersesViewModel import me.xizzhu.android.joshua.core.BibleReadingManager +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider import me.xizzhu.android.joshua.core.Highlight import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.VerseAnnotationManager +import me.xizzhu.android.joshua.core.provider.TimeProvider import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import javax.inject.Inject @HiltViewModel class HighlightsViewModel @Inject constructor( - bibleReadingManager: BibleReadingManager, - highlightsManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application -) : AnnotatedVersesViewModel(bibleReadingManager, highlightsManager, R.string.text_no_highlights, settingsManager, application) { + bibleReadingManager: BibleReadingManager, + highlightsManager: VerseAnnotationManager, + settingsManager: SettingsManager, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + timeProvider: TimeProvider, + application: Application +) : AnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = highlightsManager, + noItemText = R.string.text_no_highlights, + settingsManager = settingsManager, + coroutineDispatcherProvider = coroutineDispatcherProvider, + timeProvider = timeProvider, + application = application +) { override fun buildBaseItem(annotatedVerse: Highlight, bookName: String, bookShortName: String, verseText: String, sortOrder: Int): BaseItem = - HighlightItem(annotatedVerse.verseIndex, bookName, bookShortName, verseText, annotatedVerse.color, sortOrder) + HighlightItem(annotatedVerse.verseIndex, bookName, bookShortName, verseText, annotatedVerse.color, sortOrder) } @AndroidEntryPoint -class HighlightsActivity : AnnotatedVersesActivity(R.string.title_highlights) +class HighlightsActivity : AnnotatedVersesActivity(R.string.title_highlights) { + override val viewModel: HighlightsViewModel by viewModels() +} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsModule.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsModule.kt deleted file mode 100644 index 650d222e..00000000 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsModule.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2022 Xizhi Zhu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package me.xizzhu.android.joshua.annotated.highlights - -import android.app.Activity -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Highlight -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.VerseAnnotationManager - -@Module -@InstallIn(ActivityComponent::class) -object HighlightsModule { - @Provides - fun provideHighlightsActivity(activity: Activity): HighlightsActivity = activity as HighlightsActivity - - @ActivityScoped - @Provides - fun provideHighlightsViewModel( - highlightsActivity: HighlightsActivity, - bibleReadingManager: BibleReadingManager, - highlightsManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application - ): HighlightsViewModel { - val factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(HighlightsViewModel::class.java)) { - return HighlightsViewModel(bibleReadingManager, highlightsManager, settingsManager, application) as T - } - - throw IllegalArgumentException("Unsupported model class - $modelClass") - - } - } - return ViewModelProvider(highlightsActivity, factory)[HighlightsViewModel::class.java] - } -} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesActivity.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesActivity.kt index 0776d9ed..d674f787 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesActivity.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesActivity.kt @@ -18,31 +18,46 @@ package me.xizzhu.android.joshua.annotated.notes import android.app.Application import android.os.Bundle +import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.annotated.AnnotatedVersesActivity import me.xizzhu.android.joshua.annotated.AnnotatedVersesViewModel import me.xizzhu.android.joshua.core.BibleReadingManager +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider import me.xizzhu.android.joshua.core.Note import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.VerseAnnotationManager +import me.xizzhu.android.joshua.core.provider.TimeProvider import me.xizzhu.android.joshua.reading.ReadingActivity import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import javax.inject.Inject @HiltViewModel class NotesViewModel @Inject constructor( - bibleReadingManager: BibleReadingManager, - notesManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application -) : AnnotatedVersesViewModel(bibleReadingManager, notesManager, R.string.text_no_notes, settingsManager, application) { + bibleReadingManager: BibleReadingManager, + notesManager: VerseAnnotationManager, + settingsManager: SettingsManager, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + timeProvider: TimeProvider, + application: Application +) : AnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = notesManager, + noItemText = R.string.text_no_notes, + settingsManager = settingsManager, + coroutineDispatcherProvider = coroutineDispatcherProvider, + timeProvider = timeProvider, + application = application +) { override fun buildBaseItem(annotatedVerse: Note, bookName: String, bookShortName: String, verseText: String, sortOrder: Int): BaseItem = - NoteItem(annotatedVerse.verseIndex, bookShortName, verseText, annotatedVerse.note) + NoteItem(annotatedVerse.verseIndex, bookShortName, verseText, annotatedVerse.note) } @AndroidEntryPoint class NotesActivity : AnnotatedVersesActivity(R.string.title_notes) { + override val viewModel: NotesViewModel by viewModels() + override fun extrasForOpeningVerse(): Bundle = ReadingActivity.bundleForOpenNote() } diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesModule.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesModule.kt deleted file mode 100644 index c84f3596..00000000 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesModule.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2022 Xizhi Zhu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package me.xizzhu.android.joshua.annotated.notes - -import android.app.Activity -import android.app.Application -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped -import me.xizzhu.android.joshua.core.BibleReadingManager -import me.xizzhu.android.joshua.core.Note -import me.xizzhu.android.joshua.core.SettingsManager -import me.xizzhu.android.joshua.core.VerseAnnotationManager - -@Module -@InstallIn(ActivityComponent::class) -object NotesModule { - @Provides - fun provideNotesActivity(activity: Activity): NotesActivity = activity as NotesActivity - - @ActivityScoped - @Provides - fun provideNotesViewModel( - notesActivity: NotesActivity, - bibleReadingManager: BibleReadingManager, - notesManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application - ): NotesViewModel { - val factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(NotesViewModel::class.java)) { - return NotesViewModel(bibleReadingManager, notesManager, settingsManager, application) as T - } - - throw IllegalArgumentException("Unsupported model class - $modelClass") - - } - } - return ViewModelProvider(notesActivity, factory)[NotesViewModel::class.java] - } -} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/CoroutineDispatcherProvider.kt similarity index 96% rename from app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt rename to app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/CoroutineDispatcherProvider.kt index 6cb70f01..4542fee8 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/core/CoroutineDispatcherProvider.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/CoroutineDispatcherProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package me.xizzhu.android.joshua.core +package me.xizzhu.android.joshua.core.provider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/TimeProvider.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/TimeProvider.kt new file mode 100644 index 00000000..77b5246b --- /dev/null +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/core/provider/TimeProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 Xizhi Zhu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.xizzhu.android.joshua.core.provider + +import android.os.SystemClock +import java.util.Calendar + +interface TimeProvider { + val calendar: Calendar + val currentTimeMillis: Long + val elapsedRealtime: Long +} + +class DefaultTimeProvider : TimeProvider { + override val calendar: Calendar + get() = Calendar.getInstance() + + override val currentTimeMillis: Long + get() = System.currentTimeMillis() + + override val elapsedRealtime: Long + get() = SystemClock.elapsedRealtime() +} diff --git a/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt index 0a1cbbbf..16e933d3 100644 --- a/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt +++ b/app/src/main/kotlin/me/xizzhu/android/joshua/infra/BaseActivityV2.kt @@ -33,12 +33,12 @@ abstract class BaseActivityV2(KEY_STRONG_NUMBER).orEmpty() } - private val strongNumberViewModel: StrongNumberViewModel by viewModels() + override val viewModel: StrongNumberViewModel by viewModels() override fun inflateViewBinding(): ActivityStrongNumberBinding = ActivityStrongNumberBinding.inflate(layoutInflater) - override fun viewModel(): StrongNumberViewModel = strongNumberViewModel - - override fun onViewActionEmitted(viewAction: StrongNumberViewModel.ViewAction) { - when (viewAction) { - is StrongNumberViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING) - } + override fun onViewActionEmitted(viewAction: StrongNumberViewModel.ViewAction) = when (viewAction) { + is StrongNumberViewModel.ViewAction.OpenReadingScreen -> navigator.navigate(this, Navigator.SCREEN_READING) } override fun onViewStateUpdated(viewState: StrongNumberViewModel.ViewState) = with(viewBinding) { @@ -67,12 +63,18 @@ class StrongNumberActivity : BaseActivityV2 - listDialog(preview.title, preview.settings, preview.items, preview.currentPosition) + listDialog( + title = preview.title, + settings = preview.settings, + items = preview.items, + selected = preview.currentPosition, + onDismiss = { viewModel.markPreviewAsClosed() } + ) } when (val error = viewState.error) { is StrongNumberViewModel.ViewState.Error.PreviewLoadingError -> { - strongNumberViewModel.markErrorAsShown(error) + viewModel.markErrorAsShown(error) // Very unlikely to fail, so just falls back to open the verse. openVerse(error.verseToPreview) @@ -82,9 +84,9 @@ class StrongNumberActivity : BaseActivityV2 strongNumberViewModel.loadStrongNumber() }, + onPositive = { _, _ -> viewModel.loadStrongNumber() }, onNegative = { _, _ -> finish() }, - onDismiss = { strongNumberViewModel.markErrorAsShown(error) } + onDismiss = { viewModel.markErrorAsShown(error) } ) } is StrongNumberViewModel.ViewState.Error.VerseOpeningError -> { @@ -93,7 +95,7 @@ class StrongNumberActivity : BaseActivityV2 openVerse(error.verseToOpen) }, - onDismiss = { strongNumberViewModel.markErrorAsShown(error) } + onDismiss = { viewModel.markErrorAsShown(error) } ) } null -> { @@ -103,10 +105,10 @@ class StrongNumberActivity : BaseActivityV2 if (current.error == error) current.copy(error = null) else null } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt index aeee1037..5e67691a 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/AnnotatedVersesViewModelTest.kt @@ -22,46 +22,52 @@ import android.text.format.DateUtils import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.mockk.spyk +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import me.xizzhu.android.joshua.R import me.xizzhu.android.joshua.annotated.bookmarks.BookmarkItem import me.xizzhu.android.joshua.core.BibleReadingManager import me.xizzhu.android.joshua.core.Constants +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider import me.xizzhu.android.joshua.core.Settings import me.xizzhu.android.joshua.core.SettingsManager import me.xizzhu.android.joshua.core.VerseAnnotation import me.xizzhu.android.joshua.core.VerseAnnotationManager import me.xizzhu.android.joshua.core.VerseIndex -import me.xizzhu.android.joshua.infra.BaseViewModel +import me.xizzhu.android.joshua.core.provider.TimeProvider import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents +import me.xizzhu.android.joshua.tests.TestTimeProvider import me.xizzhu.android.joshua.ui.recyclerview.BaseItem import me.xizzhu.android.joshua.ui.recyclerview.TextItem import me.xizzhu.android.joshua.ui.recyclerview.TitleItem +import java.util.Calendar import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class AnnotatedVersesViewModelTest : BaseUnitTest() { private class TestAnnotatedVerse(verseIndex: VerseIndex, timestamp: Long) : VerseAnnotation(verseIndex, timestamp) private class TestAnnotatedVersesViewModel( - bibleReadingManager: BibleReadingManager, - verseAnnotationManager: VerseAnnotationManager, - settingsManager: SettingsManager, - application: Application + bibleReadingManager: BibleReadingManager, + verseAnnotationManager: VerseAnnotationManager, + settingsManager: SettingsManager, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + timeProvider: TimeProvider, + application: Application ) : AnnotatedVersesViewModel( - bibleReadingManager, verseAnnotationManager, R.string.text_no_bookmarks, settingsManager, application + bibleReadingManager, verseAnnotationManager, R.string.text_no_bookmarks, settingsManager, coroutineDispatcherProvider, timeProvider, application ) { override fun buildBaseItem( - annotatedVerse: TestAnnotatedVerse, bookName: String, bookShortName: String, verseText: String, sortOrder: Int + annotatedVerse: TestAnnotatedVerse, bookName: String, bookShortName: String, verseText: String, sortOrder: Int ): BaseItem = BookmarkItem(annotatedVerse.verseIndex, bookName, bookShortName, verseText, sortOrder) } @@ -76,161 +82,370 @@ class AnnotatedVersesViewModelTest : BaseUnitTest() { override fun setup() { super.setup() - bibleReadingManager = mockk() - every { bibleReadingManager.currentTranslation() } returns emptyFlow() + bibleReadingManager = mockk().apply { every { currentTranslation() } returns emptyFlow() } + verseAnnotationManager = mockk>().apply { every { sortOrder() } returns emptyFlow() } + settingsManager = mockk().apply { every { settings() } returns emptyFlow() } + resources = mockk().apply { + every { getStringArray(R.array.text_months) } returns arrayOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12") + } + application = mockk().apply { + every { getString(R.string.text_date_without_year, any(), any()) } answers { call -> + val params = call.invocation.args[1] as Array + "${params[0]}-${params[1]}" + } + every { getString(R.string.text_date, any(), any(), any()) } answers { call -> + val params = call.invocation.args[1] as Array + "${params[2]}-${params[0]}-${params[1]}" + } + every { getString(R.string.text_no_bookmarks) } returns "NO ANNOTATED VERSES" + every { resources } returns this@AnnotatedVersesViewModelTest.resources + } - verseAnnotationManager = mockk() - every { verseAnnotationManager.sortOrder() } returns emptyFlow() + annotatedVersesViewModel = TestAnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = verseAnnotationManager, + settingsManager = settingsManager, + coroutineDispatcherProvider = testCoroutineDispatcherProvider, + timeProvider = TestTimeProvider(), + application = application + ) + } - settingsManager = mockk() + @Test + fun `test loadAnnotatedVerses(), called in constructor, with exception`() = runTest { + every { bibleReadingManager.currentTranslation() } returns flowOf("", MockContents.kjvShortName) + every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) + coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } throws RuntimeException("random error") - resources = mockk() - every { resources.getStringArray(R.array.text_months) } returns arrayOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12") + annotatedVersesViewModel = TestAnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = verseAnnotationManager, + settingsManager = settingsManager, + coroutineDispatcherProvider = testCoroutineDispatcherProvider, + timeProvider = TestTimeProvider(), + application = application + ) + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.SORT_BY_BOOK, + items = emptyList(), + preview = null, + error = AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError + ), + annotatedVersesViewModel.viewState().first() + ) - application = mockk() - every { application.getString(R.string.text_no_bookmarks) } returns "NO ANNOTATED VERSES" - every { application.resources } returns resources + annotatedVersesViewModel.markErrorAsShown(AnnotatedVersesViewModel.ViewState.Error.SortOrderSavingError(Constants.SORT_BY_DATE)) + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.SORT_BY_BOOK, + items = emptyList(), + preview = null, + error = AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError + ), + annotatedVersesViewModel.viewState().first() + ) - annotatedVersesViewModel = TestAnnotatedVersesViewModel(bibleReadingManager, verseAnnotationManager, settingsManager, application) + annotatedVersesViewModel.markErrorAsShown(AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError) + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.SORT_BY_BOOK, + items = emptyList(), + preview = null, + error = null + ), + annotatedVersesViewModel.viewState().first() + ) } @Test - fun `test loadAnnotatedVerses() from constructor`() = runTest { + fun `test loadAnnotatedVerses(), called in constructor, with empty result`() = runTest { every { 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, emptyList()) } returns emptyMap() - every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) - coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } returns emptyList() + every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_DATE) + coEvery { verseAnnotationManager.read(Constants.SORT_BY_DATE) } returns emptyList() - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } - annotatedVersesViewModel = TestAnnotatedVersesViewModel(bibleReadingManager, verseAnnotationManager, settingsManager, application) + annotatedVersesViewModel = TestAnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = verseAnnotationManager, + settingsManager = settingsManager, + coroutineDispatcherProvider = testCoroutineDispatcherProvider, + timeProvider = TestTimeProvider(), + application = application + ) - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(1, actual.data.items.size) - assertEquals("NO ANNOTATED VERSES", (actual.data.items[0] as TextItem).title.toString()) + val actual = annotatedVersesViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) + assertEquals(Constants.SORT_BY_DATE, actual.sortOrder) + assertEquals(1, actual.items.size) + assertEquals("NO ANNOTATED VERSES", (actual.items[0] as TextItem).title.toString()) + assertNull(actual.preview) + assertNull(actual.error) } @Test - fun `test loadAnnotatedVerses() with empty result`() = runTest { - every { 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, emptyList()) } returns emptyMap() - every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) - coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } returns emptyList() + fun `test loadAnnotatedVerses(), with exception`() = runTest { + every { verseAnnotationManager.sortOrder() } throws RuntimeException("random exception") - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } annotatedVersesViewModel.loadAnnotatedVerses() - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(1, actual.data.items.size) - assertEquals("NO ANNOTATED VERSES", (actual.data.items[0] as TextItem).title.toString()) + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.SORT_BY_DATE, + items = emptyList(), + preview = null, + error = AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError + ), + annotatedVersesViewModel.viewState().first() + ) } @Test - fun `test loadAnnotatedVerses() sort by book`() = runTest { + fun `test loadAnnotatedVerses(), sort by book`() = runTest { every { 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, - listOf( - VerseIndex(0, 0, 0), - VerseIndex(0, 0, 1), - VerseIndex(0, 9, 9), - VerseIndex(1, 22, 18) - ) + MockContents.kjvShortName, + listOf( + VerseIndex(0, 0, 0), + VerseIndex(0, 0, 1), + VerseIndex(0, 0, 2), + VerseIndex(0, 9, 9), + VerseIndex(1, 22, 18) + ) ) } returns mapOf( - Pair(VerseIndex(0, 0, 0), MockContents.kjvVerses[0]), - Pair(VerseIndex(0, 0, 1), MockContents.kjvVerses[1]), - Pair(VerseIndex(0, 9, 9), MockContents.kjvExtraVerses[0]), - Pair(VerseIndex(1, 22, 18), MockContents.kjvExtraVerses[1]) + Pair(VerseIndex(0, 0, 0), MockContents.kjvVerses[0]), + Pair(VerseIndex(0, 0, 1), MockContents.kjvVerses[1]), + Pair(VerseIndex(0, 9, 9), MockContents.kjvExtraVerses[0]), + Pair(VerseIndex(1, 22, 18), MockContents.kjvExtraVerses[1]) ) every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_BOOK) coEvery { verseAnnotationManager.read(Constants.SORT_BY_BOOK) } returns listOf( - TestAnnotatedVerse(VerseIndex(0, 0, 0), 12345L), - TestAnnotatedVerse(VerseIndex(0, 0, 1), 12345L), - TestAnnotatedVerse(VerseIndex(0, 9, 9), 12345L), - TestAnnotatedVerse(VerseIndex(1, 22, 18), 12345L) + TestAnnotatedVerse(VerseIndex(0, 0, 0), 1L), + TestAnnotatedVerse(VerseIndex(0, 0, 1), 1L), + TestAnnotatedVerse(VerseIndex(0, 0, 2), 1L), + TestAnnotatedVerse(VerseIndex(0, 9, 9), 1L), + TestAnnotatedVerse(VerseIndex(1, 22, 18), 1L) ) - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } annotatedVersesViewModel.loadAnnotatedVerses() - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(6, actual.data.items.size) - assertEquals("Genesis", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(VerseIndex(0, 0, 0), (actual.data.items[1] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(0, 0, 1), (actual.data.items[2] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(0, 9, 9), (actual.data.items[3] as BookmarkItem).verseIndex) - assertEquals("Exodus", (actual.data.items[4] as TitleItem).title.toString()) - assertEquals(VerseIndex(1, 22, 18), (actual.data.items[5] as BookmarkItem).verseIndex) + val actual = annotatedVersesViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) + assertEquals(Constants.SORT_BY_BOOK, actual.sortOrder) + assertEquals(6, actual.items.size) + assertEquals(VerseIndex(0, 0, 0), (actual.items[1] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(0, 0, 1), (actual.items[2] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(0, 9, 9), (actual.items[3] as BookmarkItem).verseIndex) + assertEquals("Exodus", (actual.items[4] as TitleItem).title.toString()) + assertEquals(VerseIndex(1, 22, 18), (actual.items[5] as BookmarkItem).verseIndex) + assertNull(actual.preview) + assertNull(actual.error) } @Test - fun `test loadAnnotatedVerses() sort by date`() = runTest { - annotatedVersesViewModel = spyk(annotatedVersesViewModel) - every { annotatedVersesViewModel.formatDate(any(), any()) } answers { (it.invocation.args[1] as Long).toString() } + fun `test loadAnnotatedVerses(), called in constructor, sort by date`() = runTest { every { 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, - listOf( - VerseIndex(0, 0, 0), - VerseIndex(0, 0, 1), - VerseIndex(0, 9, 9), - VerseIndex(1, 22, 18) - ) + MockContents.kjvShortName, + listOf( + VerseIndex(0, 0, 0), + VerseIndex(0, 0, 1), + VerseIndex(0, 9, 9), + VerseIndex(1, 22, 18) + ) ) } returns mapOf( - Pair(VerseIndex(0, 0, 0), MockContents.kjvVerses[0]), - Pair(VerseIndex(0, 0, 1), MockContents.kjvVerses[1]), - Pair(VerseIndex(0, 9, 9), MockContents.kjvExtraVerses[0]), - Pair(VerseIndex(1, 22, 18), MockContents.kjvExtraVerses[1]) + Pair(VerseIndex(0, 0, 0), MockContents.kjvVerses[0]), + Pair(VerseIndex(0, 0, 1), MockContents.kjvVerses[1]), + Pair(VerseIndex(0, 9, 9), MockContents.kjvExtraVerses[0]), + Pair(VerseIndex(1, 22, 18), MockContents.kjvExtraVerses[1]) ) + every { settingsManager.settings() } returns flowOf(Settings.DEFAULT) every { verseAnnotationManager.sortOrder() } returns flowOf(Constants.SORT_BY_DATE) coEvery { verseAnnotationManager.read(Constants.SORT_BY_DATE) } returns listOf( - TestAnnotatedVerse(VerseIndex(0, 0, 0), 4L + DateUtils.DAY_IN_MILLIS * 360), - TestAnnotatedVerse(VerseIndex(0, 0, 1), 3L), - TestAnnotatedVerse(VerseIndex(0, 9, 9), 2L), - TestAnnotatedVerse(VerseIndex(1, 22, 18), 1L) + TestAnnotatedVerse(VerseIndex(0, 0, 0), 4L + DateUtils.DAY_IN_MILLIS * 367), // 1971-1-3 + TestAnnotatedVerse(VerseIndex(0, 0, 1), 3L + DateUtils.DAY_IN_MILLIS * 2), // 1970-1-3 + TestAnnotatedVerse(VerseIndex(0, 9, 9), 2L), // 1970-1-2 + TestAnnotatedVerse(VerseIndex(1, 22, 18), 1L) ) - val job = async { annotatedVersesViewModel.annotatedVerses().first { it is BaseViewModel.ViewData.Success } } - annotatedVersesViewModel.loadAnnotatedVerses() + annotatedVersesViewModel = TestAnnotatedVersesViewModel( + bibleReadingManager = bibleReadingManager, + verseAnnotationManager = verseAnnotationManager, + settingsManager = settingsManager, + coroutineDispatcherProvider = testCoroutineDispatcherProvider, + timeProvider = TestTimeProvider( + currentTimeMillis = DateUtils.DAY_IN_MILLIS * 400 // year 1971 + ), + application = application + ) - val actual = job.await() - assertTrue(actual is BaseViewModel.ViewData.Success) - assertEquals(6, actual.data.items.size) - assertEquals("31104000004", (actual.data.items[0] as TitleItem).title.toString()) - assertEquals(VerseIndex(0, 0, 0), (actual.data.items[1] as BookmarkItem).verseIndex) - assertEquals("3", (actual.data.items[2] as TitleItem).title.toString()) - assertEquals(VerseIndex(0, 0, 1), (actual.data.items[3] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(0, 9, 9), (actual.data.items[4] as BookmarkItem).verseIndex) - assertEquals(VerseIndex(1, 22, 18), (actual.data.items[5] as BookmarkItem).verseIndex) + val actual = annotatedVersesViewModel.viewState().first() + assertEquals(Settings.DEFAULT, actual.settings) + assertFalse(actual.loading) + assertEquals(Constants.SORT_BY_DATE, actual.sortOrder) + assertEquals(7, actual.items.size) + assertEquals("1-3", (actual.items[0] as TitleItem).title.toString()) + assertEquals(VerseIndex(0, 0, 0), (actual.items[1] as BookmarkItem).verseIndex) + assertEquals("1970-1-3", (actual.items[2] as TitleItem).title.toString()) + assertEquals(VerseIndex(0, 0, 1), (actual.items[3] as BookmarkItem).verseIndex) + assertEquals("1970-1-1", (actual.items[4] as TitleItem).title.toString()) + assertEquals(VerseIndex(0, 9, 9), (actual.items[5] as BookmarkItem).verseIndex) + assertEquals(VerseIndex(1, 22, 18), (actual.items[6] as BookmarkItem).verseIndex) + assertNull(actual.preview) + assertNull(actual.error) } @Test - fun `test loadVersesForPreview`() = runTest { - every { bibleReadingManager.currentTranslation() } returns flowOf(MockContents.kjvShortName) - coEvery { bibleReadingManager.readVerses(MockContents.kjvShortName, 0, 0) } returns MockContents.kjvVerses + fun `test saveSortOrder() with exception`() = runTest { + coEvery { verseAnnotationManager.saveSortOrder(Constants.SORT_BY_DATE) } throws RuntimeException("random exception") + + annotatedVersesViewModel.saveSortOrder(Constants.SORT_BY_DATE) + + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + items = emptyList(), + preview = null, + error = AnnotatedVersesViewModel.ViewState.Error.SortOrderSavingError(Constants.SORT_BY_DATE) + ), + annotatedVersesViewModel.viewState().first() + ) + } + + @Test + fun `test saveSortOrder()`() = runTest { + coEvery { verseAnnotationManager.saveSortOrder(Constants.SORT_BY_DATE) } returns Unit + + annotatedVersesViewModel.markErrorAsShown(AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError) + annotatedVersesViewModel.saveSortOrder(Constants.SORT_BY_DATE) + + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, // The sort order will be updated inside the flow in init{} block, so not changed here. + items = emptyList(), + preview = null, + error = null + ), + annotatedVersesViewModel.viewState().first() + ) + } + + @Test + fun `test openVerse() with exception`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } throws RuntimeException("random exception") + + annotatedVersesViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + items = emptyList(), + preview = null, + error = AnnotatedVersesViewModel.ViewState.Error.VerseOpeningError(VerseIndex(0, 0, 0)) + ), + annotatedVersesViewModel.viewState().first() + ) + } + + @Test + fun `test openVerse()`() = runTest { + coEvery { bibleReadingManager.saveCurrentVerseIndex(VerseIndex(0, 0, 0)) } returns Unit + + val viewAction = async(Dispatchers.Unconfined) { annotatedVersesViewModel.viewAction().first() } + + annotatedVersesViewModel.markErrorAsShown(AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError) + annotatedVersesViewModel.openVerse(VerseIndex(0, 0, 0)) + + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + items = emptyList(), + preview = null, + error = null + ), + annotatedVersesViewModel.viewState().first() + ) + assertEquals(AnnotatedVersesViewModel.ViewAction.OpenReadingScreen, viewAction.await()) + } + + @Test + fun `test loadPreview() with invalid verse index`() = runTest { + annotatedVersesViewModel.loadPreview(VerseIndex.INVALID) + + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + items = emptyList(), + preview = null, + error = AnnotatedVersesViewModel.ViewState.Error.PreviewLoadingError(VerseIndex.INVALID) + ), + annotatedVersesViewModel.viewState().first() + ) + } + + @Test + 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) - val actual = annotatedVersesViewModel.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(MockContents.kjvVerses.size, (actual[1] as BaseViewModel.ViewData.Success).data.items.size) - assertEquals(1, (actual[1] as BaseViewModel.ViewData.Success).data.currentPosition) + annotatedVersesViewModel.markErrorAsShown(AnnotatedVersesViewModel.ViewState.Error.AnnotatedVersesLoadingError) + annotatedVersesViewModel.loadPreview(VerseIndex(0, 0, 1)) + + val actual = annotatedVersesViewModel.viewState().first() + assertNull(actual.settings) + assertFalse(actual.loading) + assertTrue(actual.items.isEmpty()) + assertEquals(Settings.DEFAULT, actual.preview?.settings) + assertEquals("Gen., 1", actual.preview?.title) + assertEquals(3, actual.preview?.items?.size) + assertEquals(1, actual.preview?.currentPosition) + assertNull(actual.error) + + annotatedVersesViewModel.markPreviewAsClosed() + assertEquals( + AnnotatedVersesViewModel.ViewState( + settings = null, + loading = false, + sortOrder = Constants.DEFAULT_SORT_ORDER, + items = emptyList(), + preview = null, + error = null + ), + annotatedVersesViewModel.viewState().first() + ) } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt index e417e1ba..a88bc8f1 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/bookmarks/BookmarksViewModelTest.kt @@ -28,6 +28,7 @@ import me.xizzhu.android.joshua.core.VerseAnnotationManager import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents +import me.xizzhu.android.joshua.tests.TestTimeProvider import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.BeforeTest @@ -47,23 +48,21 @@ class BookmarksViewModelTest : BaseUnitTest() { override fun setup() { super.setup() - bibleReadingManager = mockk() - every { bibleReadingManager.currentTranslation() } returns emptyFlow() - bookmarksManager = mockk() - every { bookmarksManager.sortOrder() } returns emptyFlow() - settingsManager = mockk() + bibleReadingManager = mockk().apply { every { currentTranslation() } returns emptyFlow() } + bookmarksManager = mockk>().apply { every { sortOrder() } returns emptyFlow() } + settingsManager = mockk().apply { every { settings() } returns emptyFlow() } application = mockk() - bookmarksViewModel = BookmarksViewModel(bibleReadingManager, bookmarksManager, settingsManager, application) + bookmarksViewModel = BookmarksViewModel(bibleReadingManager, bookmarksManager, settingsManager, testCoroutineDispatcherProvider, TestTimeProvider(), application) } @Test fun `test buildBaseItem`() { val actual = bookmarksViewModel.buildBaseItem( - annotatedVerse = Bookmark(VerseIndex(0, 0, 0), 1L), - bookName = MockContents.kjvBookNames[0], - bookShortName = MockContents.kjvBookShortNames[0], - verseText = MockContents.kjvVerses[0].text.text, - sortOrder = Constants.SORT_BY_BOOK + annotatedVerse = Bookmark(VerseIndex(0, 0, 0), 1L), + bookName = MockContents.kjvBookNames[0], + bookShortName = MockContents.kjvBookShortNames[0], + verseText = MockContents.kjvVerses[0].text.text, + sortOrder = Constants.SORT_BY_BOOK ) assertTrue(actual is BookmarkItem) assertEquals(VerseIndex(0, 0, 0), actual.verseIndex) diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt index 4dcfc2f4..29816867 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/highlights/HighlightsViewModelTest.kt @@ -28,6 +28,7 @@ import me.xizzhu.android.joshua.core.VerseAnnotationManager import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents +import me.xizzhu.android.joshua.tests.TestTimeProvider import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.BeforeTest @@ -47,23 +48,21 @@ class HighlightsViewModelTest : BaseUnitTest() { override fun setup() { super.setup() - bibleReadingManager = mockk() - every { bibleReadingManager.currentTranslation() } returns emptyFlow() - highlightsManager = mockk() - every { highlightsManager.sortOrder() } returns emptyFlow() - settingsManager = mockk() + bibleReadingManager = mockk().apply { every { currentTranslation() } returns emptyFlow() } + highlightsManager = mockk>().apply { every { sortOrder() } returns emptyFlow() } + settingsManager = mockk().apply { every { settings() } returns emptyFlow() } application = mockk() - highlightsViewModel = HighlightsViewModel(bibleReadingManager, highlightsManager, settingsManager, application) + highlightsViewModel = HighlightsViewModel(bibleReadingManager, highlightsManager, settingsManager, testCoroutineDispatcherProvider, TestTimeProvider(), application) } @Test fun `test buildBaseItem`() { val actual = highlightsViewModel.buildBaseItem( - annotatedVerse = Highlight(VerseIndex(0, 0, 0), Highlight.COLOR_BLUE, 1L), - bookName = MockContents.kjvBookNames[0], - bookShortName = MockContents.kjvBookShortNames[0], - verseText = MockContents.kjvVerses[0].text.text, - sortOrder = Constants.SORT_BY_BOOK + annotatedVerse = Highlight(VerseIndex(0, 0, 0), Highlight.COLOR_BLUE, 1L), + bookName = MockContents.kjvBookNames[0], + bookShortName = MockContents.kjvBookShortNames[0], + verseText = MockContents.kjvVerses[0].text.text, + sortOrder = Constants.SORT_BY_BOOK ) assertTrue(actual is HighlightItem) assertEquals(VerseIndex(0, 0, 0), actual.verseIndex) diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt index 192f2258..f1ed03e1 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/annotated/notes/NotesViewModelTest.kt @@ -28,6 +28,7 @@ import me.xizzhu.android.joshua.core.VerseAnnotationManager import me.xizzhu.android.joshua.core.VerseIndex import me.xizzhu.android.joshua.tests.BaseUnitTest import me.xizzhu.android.joshua.tests.MockContents +import me.xizzhu.android.joshua.tests.TestTimeProvider import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.BeforeTest @@ -47,23 +48,21 @@ class NotesViewModelTest : BaseUnitTest() { override fun setup() { super.setup() - bibleReadingManager = mockk() - every { bibleReadingManager.currentTranslation() } returns emptyFlow() - notesManager = mockk() - every { notesManager.sortOrder() } returns emptyFlow() - settingsManager = mockk() + bibleReadingManager = mockk().apply { every { currentTranslation() } returns emptyFlow() } + notesManager = mockk>().apply { every { sortOrder() } returns emptyFlow() } + settingsManager = mockk().apply { every { settings() } returns emptyFlow() } application = mockk() - notesViewModel = NotesViewModel(bibleReadingManager, notesManager, settingsManager, application) + notesViewModel = NotesViewModel(bibleReadingManager, notesManager, settingsManager, testCoroutineDispatcherProvider, TestTimeProvider(), application) } @Test fun `test buildBaseItem`() { val actual = notesViewModel.buildBaseItem( - annotatedVerse = Note(VerseIndex(0, 0, 0), "a note", 1L), - bookName = MockContents.kjvBookNames[0], - bookShortName = MockContents.kjvBookShortNames[0], - verseText = MockContents.kjvVerses[0].text.text, - sortOrder = Constants.SORT_BY_BOOK + annotatedVerse = Note(VerseIndex(0, 0, 0), "a note", 1L), + bookName = MockContents.kjvBookNames[0], + bookShortName = MockContents.kjvBookShortNames[0], + verseText = MockContents.kjvVerses[0].text.text, + sortOrder = Constants.SORT_BY_BOOK ) assertTrue(actual is NoteItem) assertEquals(VerseIndex(0, 0, 0), actual.verseIndex) diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt index af4faa6f..0d7ee86e 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/strongnumber/StrongNumberViewModelTest.kt @@ -249,5 +249,17 @@ class StrongNumberViewModelTest : BaseUnitTest() { assertEquals(3, actual.preview?.items?.size) assertEquals(1, actual.preview?.currentPosition) assertNull(actual.error) + + strongNumberViewModel.markPreviewAsClosed() + assertEquals( + StrongNumberViewModel.ViewState( + settings = null, + loading = false, + items = emptyList(), + preview = null, + error = null + ), + strongNumberViewModel.viewState().first() + ) } } diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt index 173a0d4b..82a850fb 100644 --- a/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/BaseUnitTest.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import me.xizzhu.android.joshua.core.CoroutineDispatcherProvider +import me.xizzhu.android.joshua.core.provider.CoroutineDispatcherProvider import java.util.* import kotlin.test.AfterTest import kotlin.test.BeforeTest diff --git a/app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestTimeProvider.kt b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestTimeProvider.kt new file mode 100644 index 00000000..468075f0 --- /dev/null +++ b/app/src/test/kotlin/me/xizzhu/android/joshua/tests/TestTimeProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 Xizhi Zhu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.xizzhu.android.joshua.tests + +import me.xizzhu.android.joshua.core.provider.TimeProvider +import java.util.Calendar + +class TestTimeProvider( + override val calendar: Calendar = Calendar.getInstance(), + override val currentTimeMillis: Long = System.currentTimeMillis(), + override val elapsedRealtime: Long = 0L +) : TimeProvider