diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 1f002663bcd4..dbcb18f9171f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -102,15 +102,13 @@ import com.ichi2.anki.databinding.ActivityHomescreenBinding import com.ichi2.anki.databinding.IncludeDeckPickerBinding import com.ichi2.anki.databinding.IncludeFloatingAddButtonBinding import com.ichi2.anki.deckpicker.BackgroundImage -import com.ichi2.anki.deckpicker.DeckDeletionResult import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.deckpicker.DeckPickerViewModel.AnkiDroidEnvironment import com.ichi2.anki.deckpicker.DeckPickerViewModel.FlattenedDeckList import com.ichi2.anki.deckpicker.DeckPickerViewModel.StartupResponse -import com.ichi2.anki.deckpicker.EmptyCardsResult import com.ichi2.anki.deckpicker.OptionsMenuState -import com.ichi2.anki.deckpicker.ShortcutData import com.ichi2.anki.deckpicker.SyncIconState +import com.ichi2.anki.deckpicker.UiEvent import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.BackupPromptDialog import com.ichi2.anki.dialogs.CreateDeckDialog @@ -609,15 +607,15 @@ open class DeckPicker : @Suppress("UNUSED_PARAMETER") private fun setupFlows() { - fun onDeckDeleted(result: DeckDeletionResult) { + fun onDeckDeleted(event: UiEvent.DeckDeleted) { floatingActionButtonBinding.fabMain.isVisible = true - showSnackbar(result.toHumanReadableString(), Snackbar.LENGTH_SHORT) { + showSnackbar(event.toHumanReadableString(), Snackbar.LENGTH_SHORT) { setAction(R.string.undo) { undo() } } } - fun onCardsEmptied(result: EmptyCardsResult) { - showSnackbar(result.toHumanReadableString(), Snackbar.LENGTH_SHORT) { + fun onCardsEmptied(event: UiEvent.EmptyCardsDeleted) { + showSnackbar(event.toHumanReadableString(), Snackbar.LENGTH_SHORT) { setAction(R.string.undo) { undo() } } } @@ -779,15 +777,20 @@ open class DeckPicker : .show() } - viewModel.deckDeletedNotification.launchCollectionInLifecycleScope(::onDeckDeleted) - viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied) - viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged) - viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged) - viewModel.flowOfExportDeck.launchCollectionInLifecycleScope(::onExportDeck) - viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon) - viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts) - viewModel.onError.launchCollectionInLifecycleScope(::onError) - viewModel.flowOfPromptUserToUpdateScheduler.launchCollectionInLifecycleScope(::onPromptUserToUpdateScheduler) + viewModel.uiEvents.launchCollectionInLifecycleScope { event -> + when (event) { + is UiEvent.DeckDeleted -> onDeckDeleted(event) + is UiEvent.EmptyCardsDeleted -> onCardsEmptied(event) + is UiEvent.Navigate -> onDestinationChanged(event.destination) + is UiEvent.ShowError -> onError(event.message) + is UiEvent.ExportDeck -> onExportDeck(event.deckId) + is UiEvent.CreateShortcut -> createIcon(event) + is UiEvent.DisableShortcuts -> disableDeckAndChildrenShortcuts(event.deckIds) + UiEvent.PromptUpdateScheduler -> onPromptUserToUpdateScheduler(Unit) + UiEvent.DecksReloaded -> onDecksReloaded(Unit) + UiEvent.DeckCountsChanged -> onDeckCountsChanged(Unit) + } + } viewModel.flowOfOptionsMenuState.filterNotNull().launchCollectionInLifecycleScope(::onOptionsMenuUpdated) viewModel.flowOfStudiedTodayStats.launchCollectionInLifecycleScope(::onStudiedTodayChanged) viewModel.flowOfDeckListInInitialState.filterNotNull().launchCollectionInLifecycleScope(::onCollectionStatusChanged) @@ -796,7 +799,6 @@ open class DeckPicker : viewModel.flowOfDeckList.launchCollectionInLifecycleScope(::onDeckListChanged) viewModel.flowOfFocusedDeck.launchCollectionInLifecycleScope(::onFocusedDeckChanged) viewModel.flowOfResizingDividerVisible.launchCollectionInLifecycleScope(::onResizingDividerVisibilityChanged) - viewModel.flowOfDecksReloaded.launchCollectionInLifecycleScope(::onDecksReloaded) viewModel.flowOfStartupResponse.filterNotNull().launchCollectionInLifecycleScope(::onStartupResponse) } @@ -1983,16 +1985,16 @@ open class DeckPicker : } } - private fun createIcon(shortcutData: ShortcutData) { + private fun createIcon(event: UiEvent.CreateShortcut) { // This code should not be reachable with lower versions val shortcut = ShortcutInfoCompat - .Builder(this, shortcutData.deckId.toString()) + .Builder(this, event.deckId.toString()) .setIntent( - intentToReviewDeckFromShortcuts(this, shortcutData.deckId), + intentToReviewDeckFromShortcuts(this, event.deckId), ).setIcon(IconCompat.createWithResource(this, R.mipmap.ic_launcher)) - .setShortLabel(shortcutData.shortLabel) - .setLongLabel(shortcutData.longLabel) + .setShortLabel(event.shortLabel) + .setLongLabel(event.longLabel) .build() try { val success = ShortcutManagerCompat.requestPinShortcut(this, shortcut, null) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index b3de501632ed..a13d12ae0646 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.viewModelScope import anki.card_rendering.EmptyCardsReport import anki.collection.OpChanges import anki.decks.SetDeckCollapsedRequest -import anki.i18n.GeneratedTranslations import anki.sync.SyncStatusResponse import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR @@ -55,6 +54,7 @@ import com.ichi2.anki.performBackupInBackground import com.ichi2.anki.reviewreminders.ScheduleRemindersDestination import com.ichi2.anki.settings.Prefs import com.ichi2.anki.syncAuth +import com.ichi2.anki.ui.ChannelUiEventHost import com.ichi2.anki.utils.Destination import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -129,22 +129,22 @@ class DeckPickerViewModel : }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = FlattenedDeckList.empty) /** - * @see deleteDeck - * @see DeckDeletionResult + * Single channel of one-shot UI events for [DeckPicker]. + * + * Use this for navigation, snackbars, dialogs, error messages, and other + * fire-once events. Do *not* use it for ongoing state — keep that as `StateFlow`. */ - val deckDeletedNotification = MutableSharedFlow(extraBufferCapacity = 1) - val emptyCardsNotification = MutableSharedFlow(extraBufferCapacity = 1) - val flowOfDestination = MutableSharedFlow(extraBufferCapacity = 1) - override val onError = MutableSharedFlow(extraBufferCapacity = 1) - val flowOfExportDeck = MutableSharedFlow() - val flowOfCreateShortcut = MutableSharedFlow() - val flowOfDisableShortcuts = MutableSharedFlow>() + private val events = ChannelUiEventHost() + val uiEvents = events.uiEvents /** - * A notification that the study counts have changed + * Errors emitted via the standard `launchCatchingIO { }` machinery. + * + * This satisfies [OnErrorListener] (which `launchCatchingIO` depends on) and is + * forwarded into [uiEvents] as [UiEvent.ShowError] in [init]. + * Prefer collecting [uiEvents] in the UI rather than this flow directly. */ - // TODO: most of the recalculation should be moved inside the ViewModel - val flowOfDeckCountsChanged = MutableSharedFlow(extraBufferCapacity = 1) + override val onError = MutableSharedFlow(extraBufferCapacity = 1) var loadDeckCounts: Job? = null private set @@ -155,8 +155,6 @@ class DeckPickerViewModel : */ private var schedulerUpgradeDialogShownForVersion: Long? = null - val flowOfPromptUserToUpdateScheduler = MutableSharedFlow(extraBufferCapacity = 1) - val flowOfCollectionHasNoCards = MutableStateFlow(true) val flowOfDeckListInInitialState = @@ -181,16 +179,13 @@ class DeckPickerViewModel : !(isInInitialState == true || hasNoCards) } - // HACK: dismiss a legacy progress bar - // TODO: Replace with better progress handling for first load/corrupt collections - // This MutableSharedFlow has replay=1 due to a race condition between its collector being started - // and a possible early emission that occurs when the user is on a metered network and a dialog has to show up - // to ask the user if they want to trigger a sync. Normally, the spinning progress indicator is - // dismissed via an emission to this flow after the sync is completed, but if the metered network - // warning dialog appears, we should immediately refresh the UI in case the user decides not to sync. - // Otherwise, the progress indicator remains indefinitely. This replay=1 ensures that the collector will - // receive the dismissal event even if it starts after the emission. - val flowOfDecksReloaded = MutableSharedFlow(extraBufferCapacity = 1, replay = 1) + init { + // Bridge `OnErrorListener.onError` (driven by `launchCatchingIO { }`) into the + // unified side-effects channel so the UI only needs one collector. + viewModelScope.launch { + onError.collect { events.emit(UiEvent.ShowError(it)) } + } + } // TODO: Use a sensible default rather than null val flowOfOptionsMenuState = MutableStateFlow(null) @@ -211,9 +206,7 @@ class DeckPickerViewModel : // to match and avoid unnecessary scrolls in `renderPage()`. focusedDeck = Consts.DEFAULT_DECK_ID - deckDeletedNotification.emit( - DeckDeletionResult(deckName = deckName, cardsDeleted = changes.count), - ) + events.emit(UiEvent.DeckDeleted(deckName = deckName, cardsDeleted = changes.count)) } /** @@ -251,7 +244,7 @@ class DeckPickerViewModel : } } val result = undoableOp { removeCardsAndOrphanedNotes(toDelete) } - emptyCardsNotification.emit(EmptyCardsResult(cardsDeleted = result.count)) + events.emit(UiEvent.EmptyCardsDeleted(cardsDeleted = result.count)) } // TODO: move withProgress to the ViewModel, so we don't return 'Job' @@ -260,7 +253,7 @@ class DeckPickerViewModel : Timber.i("empty filtered deck %s", deckId) withCol { decks.select(deckId) } undoableOp { sched.emptyFilteredDeck(decks.selected()) } - flowOfDeckCountsChanged.emit(Unit) + events.emit(UiEvent.DeckCountsChanged) } /** @@ -274,7 +267,7 @@ class DeckPickerViewModel : decks.select(deckId) sched.rebuildFilteredDeck(decks.selected()) } - flowOfDeckCountsChanged.emit(Unit) + events.emit(UiEvent.DeckCountsChanged) } /** @@ -291,7 +284,7 @@ class DeckPickerViewModel : fun browseCards(deckId: DeckId) = launchCatchingIO { withCol { decks.select(deckId) } - flowOfDestination.emit(BrowserDestination.ToDeck(deckId)) + events.emit(UiEvent.Navigate(BrowserDestination.ToDeck(deckId))) } fun addNote( @@ -301,13 +294,13 @@ class DeckPickerViewModel : if (deckId != null && setAsCurrent) { withCol { decks.select(deckId) } } - flowOfDestination.emit(NoteEditorLauncher.AddNote(deckId)) + events.emit(UiEvent.Navigate(NoteEditorLauncher.AddNote(deckId))) } /** * Opens the Manage Note Types screen. */ - fun openManageNoteTypes() = launchCatchingIO { flowOfDestination.emit(ManageNoteTypesDestination()) } + fun openManageNoteTypes() = launchCatchingIO { events.emit(UiEvent.Navigate(ManageNoteTypesDestination())) } /** * Opens study options for the provided deck @@ -321,7 +314,7 @@ class DeckPickerViewModel : ) = launchCatchingIO { // open cram options if filtered deck, otherwise open regular options val filtered = isFiltered ?: withCol { decks.isFiltered(deckId) } - flowOfDestination.emit(DeckOptionsDestination(deckId = deckId, isFiltered = filtered)) + events.emit(UiEvent.Navigate(DeckOptionsDestination(deckId = deckId, isFiltered = filtered))) } fun unburyDeck(deckId: DeckId) = @@ -331,7 +324,7 @@ class DeckPickerViewModel : fun scheduleReviewReminders(deckId: DeckId) = viewModelScope.launch { - flowOfDestination.emit(ScheduleRemindersDestination(deckId)) + events.emit(UiEvent.Navigate(ScheduleRemindersDestination(deckId))) } /** @@ -384,7 +377,7 @@ class DeckPickerViewModel : if (currentSchedulerVersion == 1L && schedulerUpgradeDialogShownForVersion != 1L) { schedulerUpgradeDialogShownForVersion = 1L - flowOfPromptUserToUpdateScheduler.emit(Unit) + events.emit(UiEvent.PromptUpdateScheduler) } else { schedulerUpgradeDialogShownForVersion = currentSchedulerVersion } @@ -394,7 +387,7 @@ class DeckPickerViewModel : focusedDeck = withCol { decks.current().id } refreshUndoMenuState() - flowOfDecksReloaded.emit(Unit) + events.emit(UiEvent.DecksReloaded) } this.loadDeckCounts = loadDeckCounts return loadDeckCounts @@ -427,7 +420,7 @@ class DeckPickerViewModel : */ fun exportDeck(deckId: DeckId) = launchCatchingIO { - flowOfExportDeck.emit(deckId) + events.emit(UiEvent.ExportDeck(deckId)) } /** @@ -462,8 +455,8 @@ class DeckPickerViewModel : fullName, ) } - flowOfCreateShortcut.emit( - ShortcutData( + events.emit( + UiEvent.CreateShortcut( deckId = deckId, shortLabel = shortLabel, longLabel = longLabel, @@ -475,7 +468,7 @@ class DeckPickerViewModel : fun disableDeckAndChildrenShortcuts(deckId: DeckId) = launchCatchingIO { val deckTreeDids = dueTree?.find(deckId)?.map { it.did.toString() } ?: emptyList() - flowOfDisableShortcuts.emit(deckTreeDids) + events.emit(UiEvent.DisableShortcuts(deckTreeDids)) } sealed class StartupResponse { @@ -647,46 +640,8 @@ class DeckPickerViewModel : } } -/** Result of [DeckPickerViewModel.deleteDeck] */ -data class DeckDeletionResult( - val deckName: String, - val cardsDeleted: Int, -) { - /** - * @see GeneratedTranslations.browsingCardsDeletedWithDeckname - */ - // TODO: Somewhat questionable meaning: {count} cards deleted from {deck_name}. - @CheckResult - fun toHumanReadableString() = - TR.browsingCardsDeletedWithDeckname( - count = cardsDeleted, - deckName = deckName, - ) -} - -/** Result of [DeckPickerViewModel.deleteEmptyCards] */ -data class EmptyCardsResult( - val cardsDeleted: Int, -) { - /** - * @see GeneratedTranslations.emptyCardsDeletedCount */ - @CheckResult - fun toHumanReadableString() = TR.emptyCardsDeletedCount(cardsDeleted) -} - fun DeckNode.onlyHasDefaultDeck() = children.singleOrNull()?.did == DEFAULT_DECK_ID -/** - * Data for creating a deck shortcut - * @param shortLabel the basename of the deck (e.g., "Verbs" for "Language::English::Verbs") - * @param longLabel the full deck name (e.g., "Language::English::Verbs") - */ -data class ShortcutData( - val deckId: DeckId, - val shortLabel: String, - val longLabel: String, -) - enum class SyncIconState { Normal, PendingChanges, @@ -694,6 +649,72 @@ enum class SyncIconState { NotLoggedIn, } +/** + * One-shot UI events emitted by [DeckPickerViewModel] over a single channel and + * collected by [DeckPicker]. Use for navigation, snackbars, dialogs, errors — + * not for ongoing state (use a `StateFlow` for that). + */ +sealed interface UiEvent { + /** A deck (and possibly child decks) was deleted. @see DeckPickerViewModel.deleteDeck */ + data class DeckDeleted( + val deckName: String, + val cardsDeleted: Int, + ) : UiEvent { + /** + * @see anki.i18n.GeneratedTranslations.browsingCardsDeletedWithDeckname + */ + // TODO: Somewhat questionable meaning: {count} cards deleted from {deck_name}. + @CheckResult + fun toHumanReadableString() = + TR.browsingCardsDeletedWithDeckname( + count = cardsDeleted, + deckName = deckName, + ) + } + + /** Empty cards were removed. @see DeckPickerViewModel.deleteEmptyCards */ + data class EmptyCardsDeleted( + val cardsDeleted: Int, + ) : UiEvent { + /** @see anki.i18n.GeneratedTranslations.emptyCardsDeletedCount */ + @CheckResult + fun toHumanReadableString() = TR.emptyCardsDeletedCount(cardsDeleted) + } + + data class Navigate( + val destination: Destination, + ) : UiEvent + + data class ShowError( + val message: String, + ) : UiEvent + + data class ExportDeck( + val deckId: DeckId, + ) : UiEvent + + /** + * Request to create a launcher shortcut for a deck. + * @param shortLabel the basename of the deck (e.g., "Verbs" for "Language::English::Verbs") + * @param longLabel the full deck name (e.g., "Language::English::Verbs") + */ + data class CreateShortcut( + val deckId: DeckId, + val shortLabel: String, + val longLabel: String, + ) : UiEvent + + data class DisableShortcuts( + val deckIds: List, + ) : UiEvent + + data object PromptUpdateScheduler : UiEvent + + data object DecksReloaded : UiEvent + + data object DeckCountsChanged : UiEvent +} + /** Menu state data for the options menu */ data class OptionsMenuState( val searchIcon: Boolean, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/UiEventHost.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/UiEventHost.kt new file mode 100644 index 000000000000..f59c94d0415a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/UiEventHost.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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.ui + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * One-shot UI events from a ViewModel to its UI, using a single hot Flow. + * + * Each event must be delivered EXACTLY once, buffered while the UI is in the background, + * and never replayed on re-subscription. + * + * This is the wrong choice for ongoing state (use `StateFlow` instead). + */ +interface UiEventHost { + val uiEvents: Flow + + suspend fun emit(event: T) + + fun tryEmit(event: T): Boolean +} + +/** + * Implements [UiEventHost] using a [Channel] + * + * @see UiEventHost + */ +class ChannelUiEventHost( + capacity: Int = Channel.BUFFERED, +) : UiEventHost { + private val channel = Channel(capacity) + override val uiEvents: Flow = channel.receiveAsFlow() + + override suspend fun emit(event: T) = channel.send(event) + + override fun tryEmit(event: T): Boolean = channel.trySend(event).isSuccess +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 781fd1040835..3d9ef1918c5e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -18,7 +18,6 @@ import androidx.core.view.children import androidx.test.core.app.ActivityScenario import anki.collection.opChanges import anki.scheduler.CardAnswer.Rating -import app.cash.turbine.test import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.deckpicker.DeckPickerViewModel @@ -35,7 +34,6 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.windows.permissions.PermissionsActivity import com.ichi2.anki.ui.windows.permissions.PermissionsActivity.Companion.PERMISSIONS_SET_EXTRA -import com.ichi2.anki.utils.Destination import com.ichi2.anki.utils.ext.defaultConfig import com.ichi2.anki.utils.ext.dismissAllDialogFragments import com.ichi2.testutils.BackendEmulatingOpenConflict @@ -72,13 +70,13 @@ import org.robolectric.Robolectric import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowActivity import org.robolectric.shadows.ShadowDialog import org.robolectric.shadows.ShadowLooper import timber.log.Timber import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds typealias ContextMenuOption = DeckPickerContextMenuOption @@ -422,16 +420,19 @@ class DeckPickerTest : RobolectricTest() { @Test fun `ContextMenu starts expected activities when specific options are selected`() = deckPicker { - suspend fun DeckPicker.selectContextMenuOptionForActivity( + fun DeckPicker.selectContextMenuOptionForActivity( option: ContextMenuOption, deckId: DeckId, ): Intent { - var result: Destination? = null - viewModel.flowOfDestination.test(1.seconds) { - selectContextMenuOption(option, deckId) - result = awaitItem() - } - return result!!.toIntent(this) + shadowOf(this).drainStartedActivities() + + selectContextMenuOption(option, deckId) + advanceRobolectricLooper() + + return assertNotNull( + shadowOf(this).nextStartedActivity, + "no activity was started for $option", + ) } val didA = addDeck("Deck 1") @@ -797,6 +798,15 @@ class DeckPickerTest : RobolectricTest() { } } +/** + * Ensures `ShadowActivity.nextStartedActivity` does not return past values. + * + * @see ShadowActivity.nextStartedActivity + */ +private fun ShadowActivity.drainStartedActivities() { + while (nextStartedActivity != null) { /* drain */ } +} + fun RobolectricTest.setIntroductionSlidesShown(shown: Boolean) { getPreferences().edit { putBoolean(IntroductionActivity.INTRODUCTION_SLIDES_SHOWN, shown) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt index 3f4d2dc1afae..0cfa5f498b0a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt @@ -29,6 +29,7 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Note import com.ichi2.anki.libanki.emptyCids import com.ichi2.testutils.ensureOpsExecuted +import kotlinx.coroutines.flow.filterIsInstance import org.hamcrest.CoreMatchers.not import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo @@ -47,7 +48,7 @@ class DeckPickerViewModelTest : RobolectricTest() { runTest { val cardsToEmpty = createEmptyCards() - viewModel.emptyCardsNotification.test { + viewModel.uiEvents.filterIsInstance().test { // test a 'normal' deletion viewModel.deleteEmptyCards(cardsToEmpty).join() @@ -91,7 +92,7 @@ class DeckPickerViewModelTest : RobolectricTest() { runTest { val emptyCardsReport = createEmptyCards() - viewModel.emptyCardsNotification.test { + viewModel.uiEvents.filterIsInstance().test { viewModel.deleteEmptyCards(emptyCardsReport, preserveNotes = true).join() expectMostRecentItem().also { @@ -121,7 +122,7 @@ class DeckPickerViewModelTest : RobolectricTest() { @Test fun `empty filtered - flows`() { runTest { - viewModel.flowOfDeckCountsChanged.test { + viewModel.uiEvents.filterIsInstance().test { val filteredDeckId = moveAllCardsToFilteredDeck() expectNoEvents() viewModel.emptyFilteredDeck(filteredDeckId).join() @@ -194,10 +195,10 @@ class DeckPickerViewModelTest : RobolectricTest() { // add other decks as well as control addDeck("B") addDeck("B:B1") - viewModel.flowOfDisableShortcuts.test { + viewModel.uiEvents.filterIsInstance().test { viewModel.reloadDeckCounts().join() viewModel.disableDeckAndChildrenShortcuts(deckIdA) - val actual = awaitItem() + val actual = awaitItem().deckIds val expected = listOf( deckIdA.toString(),