diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index f780e7176e0a..bffb0b738e02 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -256,13 +256,8 @@ open class CardBrowser : registerDeckSelectedHandler(action = ::onDeckSelected) registerFindReplaceHandler { result -> launchCatchingTask { - withProgress { - val count = - withProgress { - viewModel.findAndReplace(result) - }.await() - showSnackbar(TR.browsingNotesUpdated(count)) - } + val count = viewModel.findAndReplace(result).await() + showSnackbar(TR.browsingNotesUpdated(count)) } } registerSavedSearchActionHandler { type, searchName -> diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index 664915369676..0da1f477aa7c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -28,6 +28,7 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.CheckResult import androidx.annotation.LayoutRes +import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.ThemeUtils @@ -129,8 +130,8 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.model.CardsOrNotes.CARDS import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.observability.ChangeManager -import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.previewer.PreviewerFragment +import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.requireAnkiActivity import com.ichi2.anki.requireNavigationDrawerActivity import com.ichi2.anki.scheduling.ForgetCardsDialog @@ -152,7 +153,6 @@ import com.ichi2.anki.utils.ext.visibleItemPositions import com.ichi2.anki.utils.hideKeyboard import com.ichi2.anki.withProgress import com.ichi2.ui.CardBrowserSearchView -import com.ichi2.utils.TagsUtil.getUpdatedTags import com.ichi2.utils.increaseHorizontalPaddingOfOverflowMenuIcons import com.ichi2.utils.moveCursorToEnd import com.ichi2.utils.replaceText @@ -322,6 +322,8 @@ class CardBrowserFragment : (parentFragmentManager.findFragmentByTag(DeckSelectionDialog.TAG) as? DeckSelectionDialog)?.dismiss() } + observeProgress(activityViewModel) { progress -> getString(progress.messageRes) } + // onSearchForDecks starts deck selection using childFragmentManager childFragmentManager.setFragmentResultListener(DeckSelectionDialog.REQUEST_SELECT_DECK, this) { _, bundle -> val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) @@ -1582,15 +1584,15 @@ class CardBrowserFragment : @VisibleForTesting fun toggleMark() = launchCatchingTask { - withProgress { activityViewModel.toggleMark() } + activityViewModel.toggleMark() } - fun toggleSuspendCards() = launchCatchingTask { withProgress { activityViewModel.toggleSuspendCards().join() } } + fun toggleSuspendCards() = launchCatchingTask { activityViewModel.toggleSuspendCards().join() } /** @see CardBrowserViewModel.toggleBury */ fun toggleBury() = launchCatchingTask { - val result = withProgress { activityViewModel.toggleBury() } ?: return@launchCatchingTask + val result = activityViewModel.toggleBury() ?: return@launchCatchingTask // show a snackbar as there's currently no colored background for buried cards val message = when (result.wasBuried) { @@ -1664,9 +1666,7 @@ class CardBrowserFragment : fun deleteSelectedNotes() = launchCatchingTask { - withProgress(CommonString.deleting_selected_notes) { - activityViewModel.deleteSelectedNotes() - }.ifNotZero { noteCount -> + activityViewModel.deleteSelectedNotes().ifNotZero { noteCount -> val deletedMessage = resources.getQuantityString(CommonPlurals.card_browser_cards_deleted, noteCount, noteCount) showUndoSnackbar(deletedMessage) } @@ -1762,7 +1762,7 @@ class CardBrowserFragment : fun updateFlagForSelectedRows(flag: Flag) = launchCatchingTask { - withProgress { activityViewModel.updateSelectedCardsFlag(flag) } + activityViewModel.updateSelectedCardsFlag(flag) } @VisibleForTesting(otherwise = VisibleForTesting.NONE) @@ -1811,7 +1811,7 @@ class CardBrowserFragment : TagsDialogListenerAction.FILTER -> filterByTags(selectedTags, stateFilter) TagsDialogListenerAction.EDIT_TAGS -> launchCatchingTask { - editSelectedCardsTags(selectedTags, indeterminateTags) + activityViewModel.editSelectedCardsTags(selectedTags, indeterminateTags) } else -> {} } @@ -1835,7 +1835,7 @@ class CardBrowserFragment : @VisibleForTesting internal fun moveSelectedCardsToDeck(did: DeckId): Job = launchCatchingTask { - val changed = withProgress { activityViewModel.moveSelectedCardsToDeck(did).await() } + val changed = activityViewModel.moveSelectedCardsToDeck(did).await() showUndoSnackbar(TR.browsingCardsUpdated(changed.count)) } @@ -1847,14 +1847,12 @@ class CardBrowserFragment : shift: Boolean, ) = launchCatchingTask { val count = - withProgress { - activityViewModel.repositionSelectedRows( - position = position, - step = step, - shuffle = shuffle, - shift = shift, - ) - } + activityViewModel.repositionSelectedRows( + position = position, + step = step, + shuffle = shuffle, + shift = shift, + ) showSnackbar( TR.browsingChangedNewPosition(count), Snackbar.LENGTH_SHORT, @@ -1894,30 +1892,6 @@ class CardBrowserFragment : onAddNoteActivityResult.navigate(addNoteDestination) } - /** - * Updates the tags of selected/checked notes and saves them to the disk - * @param selectedTags list of checked tags - * @param indeterminateTags a list of tags which can checked or unchecked, should be ignored if not expected - * For more info on [selectedTags] and [indeterminateTags] see [com.ichi2.anki.dialogs.tags.TagsDialogListener.onSelectedTags] - */ - private suspend fun editSelectedCardsTags( - selectedTags: List, - indeterminateTags: List, - ) = withProgress { - val selectedNoteIds = activityViewModel.queryAllSelectedNoteIds().distinct() - undoableOp { - val selectedNotes = - selectedNoteIds - .map { noteId -> getNote(noteId) } - .onEach { note -> - val previousTags: List = note.tags - val updatedTags = getUpdatedTags(previousTags, selectedTags, indeterminateTags) - note.setTagsFromStr(this@undoableOp, tags.join(updatedTags)) - } - updateNotes(selectedNotes) - } - } - private fun filterByTags( selectedTags: List, cardState: CardStateFilter, @@ -2056,3 +2030,10 @@ fun buildUserSpannable( return spannable } + +@get:StringRes +private val CardBrowserProgress.messageRes: Int + get() = + when (this) { + CardBrowserProgress.DELETING_NOTES -> CommonString.deleting_selected_notes + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index c7233029667e..e76394367369 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -63,9 +63,12 @@ import com.ichi2.anki.model.SortType import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.preferences.SharedPreferencesProvider +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.getCardOrNull import com.ichi2.anki.utils.ext.setUserFlagForCards +import com.ichi2.utils.TagsUtil.getUpdatedTags import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -132,7 +135,10 @@ class CardBrowserViewModel( BrowserOptionsRepository(preferences.sharedPrefs()), private val manualInit: Boolean = false, ) : ViewModel(), - SharedPreferencesProvider by preferences { + SharedPreferencesProvider by preferences, + HasProgress { + override val progressManager = ProgressManager() + // TODO: abstract so we can use a `Context` and `pref_display_filenames_in_browser_key` val showMediaFilenames = sharedPrefs().getBoolean("card_browser_show_media_filenames", false) @@ -775,25 +781,27 @@ class CardBrowserViewModel( * otherwise, they will be unmarked */ suspend fun toggleMark() { - val cardIds = queryAllSelectedCardIds() - if (cardIds.isEmpty()) { - Timber.i("Not marking cards - nothing selected") - return - } - undoableOp(this) { - val noteIds = notesOfCards(cardIds) - // if all notes are marked, remove the mark - // if no notes are marked, add the mark - // if there is a mix, enable the mark on all - val wantMark = !noteIds.all { getNote(it).hasTag(this@undoableOp, "marked") } - Timber.i("setting mark = %b for %d notes", wantMark, noteIds.size) - if (wantMark) { - tags.bulkAdd(noteIds, "marked") - } else { - tags.bulkRemove(noteIds, "marked") + progressManager.withProgress { + val cardIds = queryAllSelectedCardIds() + if (cardIds.isEmpty()) { + Timber.i("Not marking cards - nothing selected") + return@withProgress + } + undoableOp(this@CardBrowserViewModel) { + val noteIds = notesOfCards(cardIds) + // if all notes are marked, remove the mark + // if no notes are marked, add the mark + // if there is a mix, enable the mark on all + val wantMark = !noteIds.all { getNote(it).hasTag(this@undoableOp, "marked") } + Timber.i("setting mark = %b for %d notes", wantMark, noteIds.size) + if (wantMark) { + tags.bulkAdd(noteIds, "marked") + } else { + tags.bulkRemove(noteIds, "marked") + } } + flowOfCardStateChanged.emit(Unit) } - flowOfCardStateChanged.emit(Unit) } /** @@ -801,19 +809,20 @@ class CardBrowserViewModel( * @return the number of deleted notes */ @NeedsTest("Deleting the focused row is properly handled;#18639") - suspend fun deleteSelectedNotes(): Int { - val cardIds = queryAllSelectedCardIds() - // reset the pane row if that row is about to be deleted - if (paneRow?.cardOrNoteId in cardIds) { - paneRow = null - } - return undoableOp(this@CardBrowserViewModel) { removeNotes(cardIds = cardIds) } - .count - .also { - endMultiSelectMode(SingleSelectCause.Other) - refreshSearch() + suspend fun deleteSelectedNotes(): Int = + progressManager.withProgress(message = CardBrowserProgress.DELETING_NOTES) { + val cardIds = queryAllSelectedCardIds() + // reset the pane row if that row is about to be deleted + if (paneRow?.cardOrNoteId in cardIds) { + paneRow = null } - } + undoableOp(this@CardBrowserViewModel) { removeNotes(cardIds = cardIds) } + .count + .also { + endMultiSelectMode(SingleSelectCause.Other) + refreshSearch() + } + } fun setCardsOrNotes(newValue: CardsOrNotes) = viewModelScope.launch { browserOptionsRepository.setCardsOrNotes(newValue) } @@ -1054,18 +1063,20 @@ class CardBrowserViewModel( if (!hasSelectedAnyRows()) { return@launch } - Timber.d("toggling selected cards suspend status") - val cardIds = queryAllSelectedCardIds() - - undoableOp { - val wantUnsuspend = cardIds.all { getCard(it).queue == QueueType.Suspended } - if (wantUnsuspend) { - sched.unsuspendCards(cardIds) - } else { - sched.suspendCards(cardIds).changes + progressManager.withProgress { + Timber.d("toggling selected cards suspend status") + val cardIds = queryAllSelectedCardIds() + + undoableOp { + val wantUnsuspend = cardIds.all { getCard(it).queue == QueueType.Suspended } + if (wantUnsuspend) { + sched.unsuspendCards(cardIds) + } else { + sched.suspendCards(cardIds).changes + } } + Timber.d("finished 'toggleSuspendCards'") } - Timber.d("finished 'toggleSuspendCards'") } /** @@ -1087,25 +1098,27 @@ class CardBrowserViewModel( // https://github.com/ankitects/anki/blob/074becc0cee1e9ae59be701ad6c26787f74b4594/qt/aqt/browser/browser.py#L896-L902 fun Card.isBuried(): Boolean = queue == ManuallyBuried || queue == SiblingBuried - val cardIds = queryAllSelectedCardIds() + return progressManager.withProgress { + val cardIds = queryAllSelectedCardIds() - // this variable exists as `undoableOp` needs an OpChanges as return value - var wasBuried: Boolean? = null - undoableOp { - // this differs from Anki Desktop which uses the first selected card to determine the - // 'checked' status - val wantUnbury = cardIds.all { getCard(it).isBuried() } - - wasBuried = !wantUnbury - if (wantUnbury) { - Timber.i("unburying %d cards", cardIds.size) - sched.unburyCards(cardIds) - } else { - Timber.i("burying %d cards", cardIds.size) - sched.buryCards(cardIds).changes + // this variable exists as `undoableOp` needs an OpChanges as return value + var wasBuried: Boolean? = null + undoableOp { + // this differs from Anki Desktop which uses the first selected card to determine the + // 'checked' status + val wantUnbury = cardIds.all { getCard(it).isBuried() } + + wasBuried = !wantUnbury + if (wantUnbury) { + Timber.i("unburying %d cards", cardIds.size) + sched.unburyCards(cardIds) + } else { + Timber.i("burying %d cards", cardIds.size) + sched.buryCards(cardIds).changes + } } + BuryResult(wasBuried = wasBuried!!, count = cardIds.size) } - return BuryResult(wasBuried = wasBuried!!, count = cardIds.size) } fun querySelectionExportData(): Pair>? { @@ -1181,14 +1194,15 @@ class CardBrowserViewModel( step: Int, shuffle: Boolean, shift: Boolean, - ): Int { - val ids = queryAllSelectedCardIds() - - Timber.d("repositioning %d cards to %d", ids.size, position) - return undoableOp { - sched.sortCards(cids = ids, position, step = step, shuffle = shuffle, shift = shift) - }.count - } + ): Int = + progressManager.withProgress { + val ids = queryAllSelectedCardIds() + + Timber.d("repositioning %d cards to %d", ids.size, position) + undoableOp { + sched.sortCards(cids = ids, position, step = step, shuffle = shuffle, shift = shift) + }.count + } /** Returns the number of rows of the current result set */ val rowCount: Int @@ -1267,6 +1281,30 @@ class CardBrowserViewModel( setFilterQuery(searchTerms) } + /** + * Updates the tags of selected/checked notes and saves them to the disk + * @param selectedTags list of checked tags + * @param indeterminateTags a list of tags which can checked or unchecked, should be ignored if not expected + * For more info on [selectedTags] and [indeterminateTags] see [com.ichi2.anki.dialogs.tags.TagsDialogListener.onSelectedTags] + */ + suspend fun editSelectedCardsTags( + selectedTags: List, + indeterminateTags: List, + ) = progressManager.withProgress { + val selectedNoteIds = queryAllSelectedNoteIds().distinct() + undoableOp { + val selectedNotes = + selectedNoteIds + .map { noteId -> getNote(noteId) } + .onEach { note -> + val previousTags: List = note.tags + val updatedTags = getUpdatedTags(previousTags, selectedTags, indeterminateTags) + note.setTagsFromStr(this@undoableOp, tags.join(updatedTags)) + } + updateNotes(selectedNotes) + } + } + suspend fun filterByTags( selectedTags: List, cardState: CardStateFilter, @@ -1332,18 +1370,21 @@ class CardBrowserViewModel( fun moveSelectedCardsToDeck(deckId: DeckId): Deferred = viewModelScope.async { - val selectedCardIds = queryAllSelectedCardIds() - return@async undoableOp { - setDeck(selectedCardIds, deckId) + progressManager.withProgress { + val selectedCardIds = queryAllSelectedCardIds() + undoableOp { + setDeck(selectedCardIds, deckId) + } } } - suspend fun updateSelectedCardsFlag(flag: Flag): List { - val idsToChange = queryAllSelectedCardIds() - undoableOp(this) { setUserFlagForCards(cids = idsToChange, flag = flag) } - flowOfCardStateChanged.emit(Unit) - return idsToChange - } + suspend fun updateSelectedCardsFlag(flag: Flag): List = + progressManager.withProgress { + val idsToChange = queryAllSelectedCardIds() + undoableOp(this@CardBrowserViewModel) { setUserFlagForCards(cids = idsToChange, flag = flag) } + flowOfCardStateChanged.emit(Unit) + idsToChange + } /** * Turn off [Multi-Select Mode][isInMultiSelectMode] and return to normal state @@ -1483,20 +1524,22 @@ class CardBrowserViewModel( */ fun findAndReplace(result: FindReplaceResult) = viewModelScope.async { - // TODO pass the selection as the user saw it in the dialog to avoid running "find - // and replace" on a different selection - val noteIds = if (result.onlyOnSelectedNotes) queryAllSelectedNoteIds() else emptyList() - - if (result.field == TAGS_AS_FIELD) { - undoableOp { - tags.findAndReplace(noteIds, result.search, result.replacement, result.regex, result.matchCase) - }.count - } else { - val field = - if (result.field == ALL_FIELDS_AS_FIELD) null else result.field - undoableOp { - findAndReplace(noteIds, result.search, result.replacement, result.regex, field, result.matchCase) - }.count + progressManager.withProgress { + // TODO pass the selection as the user saw it in the dialog to avoid running "find + // and replace" on a different selection + val noteIds = if (result.onlyOnSelectedNotes) queryAllSelectedNoteIds() else emptyList() + + if (result.field == TAGS_AS_FIELD) { + undoableOp { + tags.findAndReplace(noteIds, result.search, result.replacement, result.regex, result.matchCase) + }.count + } else { + val field = + if (result.field == ALL_FIELDS_AS_FIELD) null else result.field + undoableOp { + findAndReplace(noteIds, result.search, result.replacement, result.regex, field, result.matchCase) + }.count + } } } @@ -1696,6 +1739,11 @@ enum class SaveSearchResult { SUCCESS, } +/** The operation [CardBrowserViewModel] is running, shown as a progress message by the UI. */ +enum class CardBrowserProgress { + DELETING_NOTES, +} + /** * Temporary file containing cards or note IDs to be passed in a Bundle. * diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt index 23fca6425617..e82396faa9af 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -70,6 +70,7 @@ import com.ichi2.anki.noteeditor.toIntent import com.ichi2.anki.observability.ensureNoOpsExecuted import com.ichi2.anki.observability.ensureOpWithHandler import com.ichi2.anki.observability.ensureOpsExecuted +import com.ichi2.anki.progress.ViewModelProgress import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.setFlagFilterSync import com.ichi2.anki.settings.Prefs @@ -82,12 +83,15 @@ import com.ichi2.testutils.ext.reopenWithLanguage import com.ichi2.testutils.mockIt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.containsInAnyOrder @@ -109,6 +113,7 @@ import java.io.File import kotlin.io.path.createTempDirectory import kotlin.io.path.pathString import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -872,6 +877,107 @@ class CardBrowserViewModelTest : JvmTest() { ensureOpWithHandler(this) { deleteSelectedNotes() } } + @Test + fun `toggle mark - handler passed to undoableOp prevents double refresh`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + ensureOpWithHandler(this) { toggleMark() } + } + + @Test + fun `flag - handler passed to undoableOp prevents double refresh`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + ensureOpWithHandler(this) { updateSelectedCardsFlag(Flag.RED) } + } + + @Test + fun `deleteSelectedNotes reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + assertProgressAround(CardBrowserProgress.DELETING_NOTES) { deleteSelectedNotes() } + } + + @Test + fun `toggleMark reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + assertProgressAround { toggleMark() } + } + + @Test + fun `toggleSuspendCards reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + assertProgressAround { toggleSuspendCards().join() } + } + + @Test + fun `updateSelectedCardsFlag reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + assertProgressAround { updateSelectedCardsFlag(Flag.RED) } + } + + @Test + fun `toggleBury reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + assertProgressAround { toggleBury() } + } + + @Test + fun `moveSelectedCardsToDeck reports progress`() = + runViewModelTest(notes = 2) { + val newDeck = addDeck("World") + selectRowsWithPositions(0) + + assertProgressAround { moveSelectedCardsToDeck(newDeck).await() } + } + + @Test + fun `repositionSelectedRows reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + + assertProgressAround { repositionSelectedRows(position = 100, step = 1, shuffle = false, shift = false) } + } + + @Test + fun `findAndReplace reports progress`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + val result = + FindReplaceResult( + search = "Front", + replacement = "Replaced", + field = FindAndReplaceDialogFragment.ALL_FIELDS_AS_FIELD, + onlyOnSelectedNotes = true, + matchCase = false, + regex = false, + ) + + assertProgressAround { findAndReplace(result).await() } + } + + @Test + fun `editSelectedCardsTags reports progress and tags the selected note`() = + runViewModelTest(notes = 2) { + selectRowsWithPositions(0) + val noteId = queryAllSelectedNoteIds().single() + + assertProgressAround { editSelectedCardsTags(selectedTags = listOf("tagged"), indeterminateTags = emptyList()) } + + assertThat(col.getNote(noteId).tags, equalTo(listOf("tagged"))) + } + /** @see #20556 */ @Test fun `delete note - no crash when cardIdToBeScrolledTo is deleted in NOTES mode`() { @@ -2200,6 +2306,25 @@ fun runCardBrowserViewModelTest( testBody: suspend CardBrowserViewModel.() -> Unit, ) = runViewModelTest(notes, manualInit, savedStateHandle, testBody) +/** + * Asserts [op] moves progress Idle, Active, Idle. Main becomes a [StandardTestDispatcher] so an op + * launched in `viewModelScope` is dispatched rather than run inline, which would hide Active. + */ +private suspend fun CardBrowserViewModel.assertProgressAround( + message: CardBrowserProgress? = null, + op: suspend () -> Unit, +) { + Dispatchers.setMain(StandardTestDispatcher(currentCoroutineContext()[TestCoroutineScheduler])) + progressManager.progress.test { + assertIs(awaitItem()) + op() + val active = assertIs>(awaitItem()) + assertEquals(message, active.message) + assertIs(awaitItem()) + expectNoEvents() + } +} + @Suppress("SameParameterValue") private fun CardBrowserViewModel.selectRowsWithPositions(vararg positions: Int) { for (pos in positions) {