diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index 14fa6d89a753..68950059a3d0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -36,28 +36,24 @@ import androidx.core.text.parseAsHtml import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import anki.collection.OpChanges import com.ichi2.anki.CollectionManager.TR -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.backend.stripHTMLScriptAndStyleTags import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.filtered.FilteredDeckOptionsFragment -import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.Decks import com.ichi2.anki.observability.ChangeManager -import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.reviewreminders.ReviewReminderScope import com.ichi2.anki.reviewreminders.ScheduleReminders import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.internationalization.sentenceCase +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.ui.CollectionMediaImageGetter -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.intellij.lang.annotations.Language import timber.log.Timber @@ -74,7 +70,8 @@ class StudyOptionsFragment : Fragment(), ChangeManager.Subscriber, MenuProvider { - private var currentContentView = CONTENT_STUDY_OPTIONS + private val viewModel: StudyOptionsViewModel by viewModels() + private lateinit var deckInfoLayout: Group private lateinit var buttonStart: Button private lateinit var textDeckName: TextView @@ -89,15 +86,14 @@ class StudyOptionsFragment : private lateinit var totalNewCardsCount: TextView private lateinit var totalCardsCount: TextView - private var retryMenuRefreshJob: Job? = null - private var fragmented = false private val buttonClickListener = View.OnClickListener { v: View -> if (v.id == R.id.studyoptions_start) { Timber.i("StudyOptionsFragment:: start study button pressed") - if (currentContentView != CONTENT_CONGRATS) { + val state = viewModel.state + if (state !is StudyOptionsState.Congrats) { parentFragmentManager.setFragmentResult( REQUEST_STUDY_OPTIONS_STUDY, bundleOf(), @@ -117,7 +113,6 @@ class StudyOptionsFragment : val studyOptionsView = inflater.inflate(R.layout.fragment_study_options, container, false) fragmented = requireActivity().javaClass != StudyOptionsActivity::class.java initAllContentViews(studyOptionsView) - refreshInterface() ChangeManager.subscribe(this) return studyOptionsView } @@ -128,6 +123,8 @@ class StudyOptionsFragment : ) { super.onViewCreated(view, savedInstanceState) requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + viewModel.flowOfState.launchCollectionInLifecycleScope(::rebuildUi) + refreshInterface() } override fun onCreateMenu( @@ -186,7 +183,7 @@ class StudyOptionsFragment : } private fun showCustomStudyContextMenu() { - val dialog = CustomStudyDialog.createInstance(deckId = col!!.decks.selected()) + val dialog = CustomStudyDialog.createInstance(deckId = viewModel.selectedDeckId) requireActivity().showDialogFragment(dialog) } @@ -194,14 +191,15 @@ class StudyOptionsFragment : when (item.itemId) { R.id.action_deck_or_study_options -> { Timber.i("StudyOptionsFragment:: Deck or study options button pressed") - if (col!!.decks.isFiltered(col!!.decks.selected())) { - val i = FilteredDeckOptionsFragment.getIntent(requireActivity(), did = col!!.decks.current().id) + val deckId = viewModel.selectedDeckId + if (viewModel.isFilteredDeck) { + val i = FilteredDeckOptionsFragment.getIntent(requireActivity(), did = deckId) Timber.i("Opening filtered deck options") onDeckOptionsActivityResult.launch(i) } else { val i = com.ichi2.anki.pages.DeckOptions - .getIntent(requireContext(), col!!.decks.current().id) + .getIntent(requireContext(), deckId) Timber.i("Opening deck options for activity result") onDeckOptionsActivityResult.launch(i) } @@ -217,99 +215,61 @@ class StudyOptionsFragment : val intent = ScheduleReminders.getIntent( requireContext(), - ReviewReminderScope.DeckSpecific(col!!.decks.current().id), + ReviewReminderScope.DeckSpecific(viewModel.selectedDeckId), ) startActivity(intent) return true } R.id.action_unbury -> { Timber.i("StudyOptionsFragment:: unbury button pressed") - launchCatchingTask { - undoableOp { sched.unburyDeck(decks.getCurrentId()) } - } + viewModel.unbury() item.isVisible = false return true } R.id.action_rebuild -> { Timber.i("StudyOptionsFragment:: rebuild cram deck button pressed") - launchCatchingTask { rebuildCram() } + launchCatchingTask { + requireActivity().withProgress(resources.getString(R.string.rebuild_filtered_deck)) { + viewModel.rebuildCram() + } + } return true } R.id.action_empty -> { Timber.i("StudyOptionsFragment:: empty cram deck button pressed") - launchCatchingTask { emptyCram() } + launchCatchingTask { + requireActivity().withProgress(resources.getString(R.string.empty_filtered_deck)) { + viewModel.emptyCram() + } + } return true } else -> return false } } - private suspend fun rebuildCram() { - val result = - requireActivity().withProgress(resources.getString(R.string.rebuild_filtered_deck)) { - undoableOp { - Timber.d("doInBackground - RebuildCram") - sched.rebuildFilteredDeck(decks.selected()) - } - withCol { fetchStudyOptionsData() } - } - rebuildUi(result) - } - - @VisibleForTesting - suspend fun emptyCram() { - val result = - requireActivity().withProgress(resources.getString(R.string.empty_filtered_deck)) { - undoableOp { - Timber.d("doInBackgroundEmptyCram") - sched.emptyFilteredDeck(decks.selected()) - } - withCol { fetchStudyOptionsData() } - } - rebuildUi(result) - } - override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) Timber.i("configureToolbarInternal()") - try { - // Switch on or off rebuild/empty/custom study depending on whether or not filtered deck - if (col != null && col!!.decks.isFiltered(col!!.decks.selected())) { - menu.findItem(R.id.action_rebuild)?.isVisible = true - menu.findItem(R.id.action_empty)?.isVisible = true - menu.findItem(R.id.action_custom_study)?.isVisible = false - menu.findItem(R.id.action_deck_or_study_options)?.setTitle(R.string.menu__study_options) - } else { - menu.findItem(R.id.action_rebuild)?.isVisible = false - menu.findItem(R.id.action_empty)?.isVisible = false - menu.findItem(R.id.action_custom_study)?.isVisible = true - menu.findItem(R.id.action_deck_or_study_options)?.setTitle(R.string.menu__deck_options) - } - // Don't show custom study icon if congrats shown - if (currentContentView == CONTENT_CONGRATS) { - menu.findItem(R.id.action_custom_study)?.isVisible = false - } - // Use new review reminders system if enabled - menu.findItem(R.id.action_schedule_reminders)?.isVisible = Prefs.newReviewRemindersEnabled - // Switch on or off unbury depending on if there are cards to unbury - menu.findItem(R.id.action_unbury)?.isVisible = col != null && col!!.sched.haveBuried() - } catch (e: IllegalStateException) { - if (!CollectionManager.isOpenUnsafe()) { - // This will allow a maximum of one invalidate menu attempt in order to workaround - // database closes caused by sync on startup where this might be running then have - // the collection close - Timber.i(e, "Database closed while working. Probably auto-sync. Will re-try after sleep.") - if (retryMenuRefreshJob != null) { - return // we already are doing a refresh, so abort to avoid entering an endless loop - } - retryMenuRefreshJob = - viewLifecycleOwner.lifecycleScope.launch { - delay(1000) - retryMenuRefreshJob = null - activity?.invalidateMenu() - } - } + val state = viewModel.state + if (state is StudyOptionsState.Loading) return + + if (viewModel.isFilteredDeck) { + menu.findItem(R.id.action_rebuild)?.isVisible = true + menu.findItem(R.id.action_empty)?.isVisible = true + menu.findItem(R.id.action_custom_study)?.isVisible = false + menu.findItem(R.id.action_deck_or_study_options)?.setTitle(R.string.menu__study_options) + } else { + menu.findItem(R.id.action_rebuild)?.isVisible = false + menu.findItem(R.id.action_empty)?.isVisible = false + menu.findItem(R.id.action_custom_study)?.isVisible = true + menu.findItem(R.id.action_deck_or_study_options)?.setTitle(R.string.menu__deck_options) } + if (state is StudyOptionsState.Congrats) { + menu.findItem(R.id.action_custom_study)?.isVisible = false + } + menu.findItem(R.id.action_schedule_reminders)?.isVisible = Prefs.newReviewRemindersEnabled + menu.findItem(R.id.action_unbury)?.isVisible = viewModel.haveBuried } private var onDeckOptionsActivityResult = @@ -333,60 +293,13 @@ class StudyOptionsFragment : } } - private var updateValuesFromDeckJob: Job? = null - fun refreshInterface() { Timber.d("Refreshing StudyOptionsFragment") - updateValuesFromDeckJob?.cancel() - // Load the deck counts for the deck from Collection asynchronously - updateValuesFromDeckJob = - launchCatchingTask { - if (CollectionManager.isOpenUnsafe()) { - val result = withCol { fetchStudyOptionsData() } - rebuildUi(result) - } - } + viewModel.refreshData() } - class DeckStudyData( - /** - * The number of new card to see today in a deck, including subdecks. - */ - val newCardsToday: Int, - /** - * The number of (repetition of) card in learning to see today in a deck, including subdecks. The exact way cards with multiple steps are counted depends on the scheduler - */ - val lrnCardsToday: Int, - /** - * The number of review card to see today in a deck, including subdecks. - */ - val revCardsToday: Int, - val buriedNew: Int, - val buriedLearning: Int, - val buriedReview: Int, - val totalNewCards: Int, - /** - * Number of cards in this decks and its subdecks. - */ - val numberOfCardsInDeck: Int, - ) - - private val col: Collection? - get() { - try { - return CollectionManager.getColUnsafe() - } catch (_: Exception) { - // This may happen if the backend is locked or similar. - } - return null - } - - override fun onPause() { - super.onPause() - updateValuesFromDeckJob?.cancel() - } - - private fun rebuildUi(result: DeckStudyData) { + private fun rebuildUi(state: StudyOptionsState) { + if (state is StudyOptionsState.Loading) return view?.findViewById(R.id.progress_bar)?.visibility = View.GONE // Don't do anything if the fragment is no longer attached to it's Activity or col has been closed if (activity == null) { @@ -396,17 +309,11 @@ class StudyOptionsFragment : // #5506 If we have no view, short circuit all UI logic val studyOptionsView = view ?: return - val col = - col - ?: throw NullPointerException("StudyOptionsFragment:: Collection is null while rebuilding Ui") - - // Reinitialize controls in case changed to filtered deck initAllContentViews(studyOptionsView) - // Set the deck name - val deck = col.decks.current() - // Main deck name - val fullName = deck.getString("name") - val name = Decks.path(fullName) + + val deckName = state.deckNameOrNull() ?: return + + val name = Decks.path(deckName) val nameBuilder = StringBuilder() if (name.isNotEmpty()) { nameBuilder.append(name[0]) @@ -422,44 +329,50 @@ class StudyOptionsFragment : } textDeckName.text = nameBuilder.toString() - // Switch between the empty view, the ordinary view, and the "congratulations" view - val isDynamic = deck.isFiltered - if (result.numberOfCardsInDeck == 0 && !isDynamic) { - currentContentView = CONTENT_EMPTY - deckInfoLayout.visibility = View.VISIBLE - buttonStart.visibility = View.GONE - } else if (result.newCardsToday + result.lrnCardsToday + result.revCardsToday == 0) { - currentContentView = CONTENT_CONGRATS - if (!isDynamic) { - deckInfoLayout.visibility = View.GONE - buttonStart.visibility = View.VISIBLE - buttonStart.text = TR.sentenceCase.customStudy - } else { + when (state) { + is StudyOptionsState.Empty -> { + deckInfoLayout.visibility = View.VISIBLE buttonStart.visibility = View.GONE } - } else { - currentContentView = CONTENT_STUDY_OPTIONS - deckInfoLayout.visibility = View.VISIBLE - buttonStart.visibility = View.VISIBLE - buttonStart.setText(R.string.studyoptions_start) + is StudyOptionsState.Congrats -> { + if (!state.isDynamic) { + deckInfoLayout.visibility = View.GONE + buttonStart.visibility = View.VISIBLE + buttonStart.text = TR.sentenceCase.customStudy + } else { + buttonStart.visibility = View.GONE + } + } + is StudyOptionsState.StudyOptions -> { + deckInfoLayout.visibility = View.VISIBLE + buttonStart.visibility = View.VISIBLE + buttonStart.setText(R.string.studyoptions_start) + } + is StudyOptionsState.Loading -> return } - // Set deck description + val isDynamic = + when (state) { + is StudyOptionsState.StudyOptions -> state.isDynamic + is StudyOptionsState.Congrats -> state.isDynamic + else -> false + } + + val description = + when (state) { + is StudyOptionsState.StudyOptions -> state.deckDescription + else -> null + } + @Language("HTML") val desc: String = if (isDynamic) { resources.getString(R.string.dyn_deck_desc) } else { - val deck = col.decks.current() - if (deck.descriptionAsMarkdown) { - @Suppress("DEPRECATION") // renderMarkdown is fine here. - col.renderMarkdown(deck.description, sanitize = true) - } else { - deck.description - } + description ?: "" } if (desc.isNotEmpty()) { - val mediaDir = col.media.dir + val mediaDir = CollectionManager.getColUnsafe().media.dir val imageGetter = CollectionMediaImageGetter( requireContext(), @@ -467,20 +380,20 @@ class StudyOptionsFragment : mediaDir, viewLifecycleOwner.lifecycleScope, ) - textDeckDescription.text = formatDescription(desc, imageGetter) textDeckDescription.visibility = View.VISIBLE } else { textDeckDescription.visibility = View.GONE } - // Set new/learn/review card counts - newCountText.text = result.newCardsToday.toString() - learningCountText.text = result.lrnCardsToday.toString() - reviewCountText.text = result.revCardsToday.toString() + val data = state.dataOrNull() ?: return + + newCountText.text = data.newCardsToday.toString() + learningCountText.text = data.lrnCardsToday.toString() + reviewCountText.text = data.revCardsToday.toString() // set bury numbers - buryInfoLabel.isVisible = result.buriedNew > 0 || result.buriedLearning > 0 || result.buriedReview > 0 + buryInfoLabel.isVisible = data.buriedNew > 0 || data.buriedLearning > 0 || data.buriedReview > 0 fun TextView.updateBuryText(count: Int) { this.isVisible = count > 0 @@ -496,42 +409,14 @@ class StudyOptionsFragment : else -> "" } } - newBuryText.updateBuryText(result.buriedNew) - learningBuryText.updateBuryText(result.buriedLearning) - reviewBuryText.updateBuryText(result.buriedReview) - totalNewCardsCount.text = result.totalNewCards.toString() - totalCardsCount.text = result.numberOfCardsInDeck.toString() - // Rebuild the options menu + newBuryText.updateBuryText(data.buriedNew) + learningBuryText.updateBuryText(data.buriedLearning) + reviewBuryText.updateBuryText(data.buriedReview) + totalNewCardsCount.text = data.totalNewCards.toString() + totalCardsCount.text = data.numberOfCardsInDeck.toString() activity?.invalidateMenu() } - /** - * See https://github.com/ankitects/anki/blob/b05c9d15986ab4e33daa2a47a947efb066bb69b6/qt/aqt/overview.py#L226-L272 - */ - private fun Collection.fetchStudyOptionsData(): DeckStudyData { - val deckId = decks.current().id - val counts = sched.counts() - var buriedNew = 0 - var buriedLearning = 0 - var buriedReview = 0 - val tree = sched.deckDueTree(deckId) - if (tree != null) { - buriedNew = tree.newCount - counts.new - buriedLearning = tree.learnCount - counts.lrn - buriedReview = tree.reviewCount - counts.rev - } - return DeckStudyData( - newCardsToday = counts.new, - lrnCardsToday = counts.lrn, - revCardsToday = counts.rev, - buriedNew = buriedNew, - buriedLearning = buriedLearning, - buriedReview = buriedReview, - totalNewCards = sched.totalNewForCurrentDeck(), - numberOfCardsInDeck = decks.cardCount(deckId, includeSubdecks = true), - ) - } - companion object { /** * Identifier for a fragment result request to study(open the reviewer). Activities using @@ -539,13 +424,6 @@ class StudyOptionsFragment : */ const val REQUEST_STUDY_OPTIONS_STUDY = "request_study_option_study" - /** - * Constants for selecting which content view to display - */ - private const val CONTENT_STUDY_OPTIONS = 0 - private const val CONTENT_CONGRATS = 1 - private const val CONTENT_EMPTY = 2 - @VisibleForTesting fun formatDescription( @Language("HTML") desc: String, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt new file mode 100644 index 000000000000..a70c031cafd3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import anki.collection.OpChanges +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.observability.undoableOp +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +class StudyOptionsViewModel : ViewModel() { + val flowOfState: StateFlow + field = MutableStateFlow(StudyOptionsState.Loading) + val state: StudyOptionsState get() = flowOfState.value + + val flowOfIsFilteredDeck: StateFlow + field = MutableStateFlow(false) + val isFilteredDeck: Boolean get() = flowOfIsFilteredDeck.value + + val flowOfHaveBuried: StateFlow + field = MutableStateFlow(false) + val haveBuried: Boolean get() = flowOfHaveBuried.value + + val flowOfSelectedDeckId: StateFlow + field = MutableStateFlow(0L) + val selectedDeckId: DeckId get() = flowOfSelectedDeckId.value + + /** + * Refreshes the deck statistics, deck name, description, and menu-state flags from + * the collection. + */ + fun refreshData(): Job = + viewModelScope.launch { + if (!CollectionManager.isOpenUnsafe()) return@launch + withCol { updateStateFromCollection() } + } + + suspend fun rebuildCram() { + Timber.d("doInBackground - RebuildCram") + undoableOp { sched.rebuildFilteredDeck(decks.selected()) } + withCol { updateStateFromCollection() } + } + + suspend fun emptyCram() { + Timber.d("doInBackgroundEmptyCram") + undoableOp { sched.emptyFilteredDeck(decks.selected()) } + withCol { updateStateFromCollection() } + } + + fun unbury(): Job = + viewModelScope.launch { + undoableOp { sched.unburyDeck(decks.getCurrentId()) } + } + + private fun Collection.updateStateFromCollection() { + val deck = decks.current() + val deckId = deck.id + val counts = sched.counts() + var buriedNew = 0 + var buriedLearning = 0 + var buriedReview = 0 + val tree = sched.deckDueTree(deckId) + if (tree != null) { + buriedNew = tree.newCount - counts.new + buriedLearning = tree.learnCount - counts.lrn + buriedReview = tree.reviewCount - counts.rev + } + val isDynamic = deck.isFiltered + val fullName = deck.getString("name") + val description = + if (isDynamic) { + null + } else { + if (deck.descriptionAsMarkdown) { + @Suppress("DEPRECATION") + renderMarkdown(deck.description, sanitize = true) + } else { + deck.description + } + } + val data = + DeckStudyData( + newCardsToday = counts.new, + lrnCardsToday = counts.lrn, + revCardsToday = counts.rev, + buriedNew = buriedNew, + buriedLearning = buriedLearning, + buriedReview = buriedReview, + totalNewCards = sched.totalNewForCurrentDeck(), + numberOfCardsInDeck = decks.cardCount(deckId, includeSubdecks = true), + ) + + flowOfIsFilteredDeck.value = isDynamic + flowOfHaveBuried.value = sched.haveBuried() + flowOfSelectedDeckId.value = decks.selected() + + val totalDue = data.newCardsToday + data.lrnCardsToday + data.revCardsToday + flowOfState.value = + when { + data.numberOfCardsInDeck == 0 && !isDynamic -> + StudyOptionsState.Empty( + deckName = fullName, + data = data, + ) + totalDue == 0 -> + StudyOptionsState.Congrats( + deckName = fullName, + isDynamic = isDynamic, + data = data, + ) + else -> + StudyOptionsState.StudyOptions( + deckName = fullName, + deckDescription = description, + isDynamic = isDynamic, + data = data, + ) + } + } +} + +class DeckStudyData( + val newCardsToday: Int, + val lrnCardsToday: Int, + val revCardsToday: Int, + val buriedNew: Int, + val buriedLearning: Int, + val buriedReview: Int, + val totalNewCards: Int, + val numberOfCardsInDeck: Int, +) + +sealed interface StudyOptionsState { + data object Loading : StudyOptionsState + + data class StudyOptions( + val deckName: String, + val deckDescription: String?, + val isDynamic: Boolean, + val data: DeckStudyData, + ) : StudyOptionsState + + data class Congrats( + val deckName: String, + val isDynamic: Boolean, + val data: DeckStudyData, + ) : StudyOptionsState + + data class Empty( + val deckName: String, + val data: DeckStudyData, + ) : StudyOptionsState +} + +/** The [DeckStudyData] for any non-[StudyOptionsState.Loading] state, or `null` while loading. */ +fun StudyOptionsState.dataOrNull(): DeckStudyData? = + when (this) { + is StudyOptionsState.StudyOptions -> data + is StudyOptionsState.Congrats -> data + is StudyOptionsState.Empty -> data + is StudyOptionsState.Loading -> null + } + +/** The deck name for any non-[StudyOptionsState.Loading] state, or `null` while loading. */ +fun StudyOptionsState.deckNameOrNull(): String? = + when (this) { + is StudyOptionsState.StudyOptions -> deckName + is StudyOptionsState.Congrats -> deckName + is StudyOptionsState.Empty -> deckName + is StudyOptionsState.Loading -> null + } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt new file mode 100644 index 000000000000..24c6989914cb --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import anki.scheduler.CardAnswer.Rating +import app.cash.turbine.test +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.testutils.ensureOpsExecuted +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.instanceOf +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class StudyOptionsViewModelTest : RobolectricTest() { + private val viewModel = StudyOptionsViewModel() + + @Test + fun `initial state is Loading`() { + assertThat(viewModel.state, instanceOf(StudyOptionsState.Loading::class.java)) + } + + @Test + fun `refreshData - empty deck shows Empty state`() = + runTest { + col + viewModel.refreshData().join() + assertIs(viewModel.state) + } + + @Test + fun `refreshData - deck with due cards shows StudyOptions state`() = + runTest { + addBasicNote("Front", "Back") + + viewModel.refreshData().join() + + val state = viewModel.state + assertIs(state) + assertEquals(1, state.data.newCardsToday) + assertEquals(1, state.data.numberOfCardsInDeck) + assertEquals(1, state.data.totalNewCards) + assertEquals(0, state.data.lrnCardsToday) + assertEquals(0, state.data.revCardsToday) + } + + @Test + fun `refreshData - regular deck is not filtered`() = + runTest { + addBasicNote() + + viewModel.refreshData().join() + + assertFalse(viewModel.isFilteredDeck) + } + + @Test + fun `refreshData - congrats state when no cards due`() = + runTest { + addBasicNote() + withCol { + while (sched.card != null) { + val card = sched.card!! + sched.answerCard(card, Rating.EASY) + } + } + + viewModel.refreshData().join() + + assertIs(viewModel.state) + } + + @Test + fun `refreshData - state flow emits updates`() = + runTest { + col + viewModel.flowOfState.test { + assertIs(awaitItem()) + + viewModel.refreshData().join() + assertIs(awaitItem()) + + addBasicNote() + viewModel.refreshData().join() + assertIs(awaitItem()) + } + } + + @Test + fun `refreshData - multiple cards counted correctly`() = + runTest { + repeat(5) { addBasicNote("Front $it", "Back $it") } + + viewModel.refreshData().join() + + val state = assertIs(viewModel.state) + assertEquals(5, state.data.newCardsToday) + assertEquals(5, state.data.numberOfCardsInDeck) + } + + @Test + fun `refreshData - deck name is correct`() = + runTest { + addBasicNote() + + viewModel.refreshData().join() + + val state = assertIs(viewModel.state) + assertEquals("Default", state.deckName) + } + + @Test + fun `rebuildCram - updates state`() = + runTest { + addBasicNote() + addDynamicDeck("Filtered", "") + + viewModel.rebuildCram() + + val state = viewModel.state + assertThat(state, instanceOf(StudyOptionsState::class.java)) + assertTrue(viewModel.isFilteredDeck) + } + + @Test + fun `emptyCram - is undoable`() = + runTest { + addBasicNote() + addDynamicDeck("Filtered", "") + + ensureOpsExecuted(1) { + viewModel.emptyCram() + } + } + + @Test + fun `rebuildCram - is undoable`() = + runTest { + addBasicNote() + addDynamicDeck("Filtered", "") + + ensureOpsExecuted(1) { + viewModel.rebuildCram() + } + } + + @Test + fun `unbury - is undoable`() = + runTest { + addBasicNote() + withCol { + val card = sched.card!! + sched.buryCards(listOf(card.id), true) + } + + ensureOpsExecuted(1) { + viewModel.unbury().join() + } + } + + @Test + fun `haveBuried - false when no buried cards`() = + runTest { + addBasicNote() + + viewModel.refreshData().join() + + assertFalse(viewModel.haveBuried) + } + + @Test + fun `refreshData - buried cards are counted`() = + runTest { + addBasicNote("Front1", "Back1") + addBasicNote("Front2", "Back2") + withCol { + val card = sched.card!! + sched.buryCards(listOf(card.id), true) + } + + viewModel.refreshData().join() + + val state = assertIs(viewModel.state) + assertTrue(state.data.buriedNew > 0, "expected buried new cards") + assertEquals(1, state.data.newCardsToday) + } +}