From cd25544e07c3caae5c112b3193a469e98c015fd5 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 7 Jul 2026 00:08:04 -0500 Subject: [PATCH] =?UTF-8?q?test(compose):=20Robolectric=20JVM=20tests=20?= =?UTF-8?q?=E2=80=94=20mailbox=20screen=20+=20folder=20drawer=20(#383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the Paging 3 mailbox list + folder drawer to Robolectric JVM Compose tests (batch 8/9 of umbrella #373) and drop them from jacocoNonJvmTestableSurface. - MailboxScreenJvmTest drives the real MailboxScreen + MailboxViewModel over mocked repositories, feeding Paging via static PagingData.from flows (no real Room/Paging source, mirroring MailboxViewModelTest). Covers the no-accounts welcome fallback, populated list (sender/subject/snippet, offline badge, unified per-account labels + filter chips, drafts/outbox entries), the empty/loading/no-results states, search open/close, and the multi-select contextual action bar (overflow, archive/spam/delete confirms, move picker, archive-hidden-in-archive, disambiguated app-bar title). - FolderDrawerJvmTest drives the callback-driven FolderDrawer: friendly role names, duplicate-name provider disambiguation + account-switch gap, folder taps, the multi-account switcher/dropdown, and the unread badge (incl. 99+ cap). - Remove **/MailboxScreen* and **/FolderDrawer* from jacocoNonJvmTestableSurface; overall JVM line coverage 84.69% (floor unchanged at 0.79). The instrumented MailboxScreenTest/FolderDrawerTest stay as the on-device E2E. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../ui/mailbox/FolderDrawerJvmTest.kt | 267 ++++++++ .../ui/mailbox/MailboxScreenJvmTest.kt | 601 ++++++++++++++++++ 3 files changed, 870 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/org/libremail/ui/mailbox/FolderDrawerJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/mailbox/MailboxScreenJvmTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ced059..64b6aa1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -322,8 +322,8 @@ val jacocoNonJvmTestableSurface = listOf( // (#379) — now JVM-covered. // LockScreen converted to a Robolectric JVM Compose test (#377) — now JVM-covered. "**/AppLockGateHost*", - "**/FolderDrawer*", - "**/MailboxScreen*", + // FolderDrawer & MailboxScreen (the Paging 3 mailbox list + folder drawer) converted to + // Robolectric JVM Compose tests (#383) — now JVM-covered. // AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps // (#377) converted to Robolectric JVM Compose tests — now JVM-covered. "**/ReaderScreen*", diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/FolderDrawerJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/FolderDrawerJvmTest.kt new file mode 100644 index 0000000..12eb044 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/FolderDrawerJvmTest.kt @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import android.content.Context +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented [FolderDrawerTest] (batch 8/9 of umbrella #373): drives the + * callback-driven [FolderDrawer] on the JVM via the v2 `createComposeRule()` — no emulator — so its + * render + interaction code counts toward JaCoCo's JVM-testable surface. Covers folder rendering with + * friendly role names, the duplicate-name provider disambiguation (and the account-switch gap that + * keeps the stale list's own suffix), tapping a folder, the multi-account switcher + "All Inboxes" + * entry, the account-switcher dropdown, and the unread badge (including the "99+" overflow cap). The + * instrumented [FolderDrawerTest] stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class FolderDrawerJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int) = context.getString(resId) + + private fun unreadDescription(count: Int) = + context.resources.getQuantityString(R.plurals.folder_unread_count_description, count, count) + + private val alice = account("imap:a", "alice@example.org") + private val bob = account("imap:b", "bob@example.org") + + @Test + fun singleAccount_rendersStandardFoldersWithFriendlyNames() { + setContent( + accounts = listOf(alice), + drawerAccount = alice, + folders = listOf( + folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX), + folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT), + // Give ARCHIVE a server name that differs from its friendly label so the assertion + // below actually discriminates a role-to-label regression (displayName != friendly). + folder("imap:a", "[Gmail]/All Mail", "All Mail", FolderRole.ARCHIVE), + folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL), + ), + ) + + composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed() + // Standard folders use the friendly role name, not the raw server name — verified for both + // Sent ("Sent Mail" -> "Sent") and Archive ("All Mail" -> "Archive"). + composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed() + composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed() + composeTestRule.onNodeWithText("All Mail").assertDoesNotExist() + // Normal folders keep their server name. + composeTestRule.onNodeWithText("Receipts").assertIsDisplayed() + // A single account shows no account switcher / "All Inboxes" entry. + composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertDoesNotExist() + } + + @Test + fun duplicateFolderNames_areDisambiguatedWithProviderSuffix() { + val gmail = account("imap:g", "user@gmail.com").copy( + imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS), + ) + setContent( + accounts = listOf(gmail), + drawerAccount = gmail, + folders = listOf( + folder("imap:g", "INBOX", "INBOX", FolderRole.INBOX), + // Gmail's built-in Drafts (server special-use) alongside a same-named user folder. + folder("imap:g", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, specialUse = true), + folder("imap:g", "Drafts", "Drafts", FolderRole.DRAFTS), + ), + ) + + // The provider's built-in folder is suffixed; the user folder keeps the plain name. + composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed() + composeTestRule.onNodeWithText("Drafts").assertIsDisplayed() + } + + @Test + fun accountSwitchGap_staleFolderListKeepsItsOwnProviderSuffix() { + val gmail = account("imap:g", "user@gmail.com").copy( + imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS), + ) + val outlook = account("imap:o", "user@outlook.com").copy( + authType = AuthType.OAUTH_OUTLOOK, + imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS), + ) + // The transient frame from issue #61: the drawer account has already switched to Outlook, + // but the folder list still holds the Gmail account's folders until its query emits. + setContent( + accounts = listOf(gmail, outlook), + drawerAccount = outlook, + folders = listOf( + folder("imap:g", "INBOX", "INBOX", FolderRole.INBOX), + folder("imap:g", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, specialUse = true), + folder("imap:g", "Drafts", "Drafts", FolderRole.DRAFTS), + ), + ) + + // The de-dup suffix derives from the folders' own account — never the incoming account. + composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed() + composeTestRule.onNodeWithText("Drafts - Outlook").assertDoesNotExist() + } + + @Test + fun tappingAFolder_reportsItsAccountAndFullName() { + var picked: Pair? = null + setContent( + accounts = listOf(alice), + drawerAccount = alice, + folders = listOf( + folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX), + folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE), + ), + onSelectFolder = { accountId, fullName -> picked = accountId to fullName }, + ) + + composeTestRule.onNodeWithText(string(R.string.folder_archive)).performClick() + + assertEquals("imap:a" to "Archive", picked) + } + + @Test + fun multipleAccounts_showSwitcherAndAllInboxesEntry() { + var unifiedTapped = false + setContent( + accounts = listOf(alice, bob), + drawerAccount = alice, + folders = listOf(folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX)), + onSelectUnifiedInbox = { unifiedTapped = true }, + ) + + // The switcher shows the active drawer account, and the unified entry is available. + composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).performClick() + assertTrue(unifiedTapped) + } + + @Test + fun accountSwitcher_expandsDropdown_andSelectsAnotherAccount() { + var switchedTo: String? = null + setContent( + accounts = listOf(alice, bob), + drawerAccount = alice, + folders = listOf(folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX)), + onSelectDrawerAccount = { switchedTo = it }, + ) + + // Only the switcher shows the current account initially; opening it lists every account. + composeTestRule.onNodeWithText("alice@example.org").performClick() + composeTestRule.onNodeWithText("bob@example.org").performClick() + + assertEquals("imap:b", switchedTo) + } + + @Test + fun folderWithUnreadMail_showsCountBadge_andReadFolderShowsNone() { + setContent( + accounts = listOf(alice), + drawerAccount = alice, + folders = listOf( + folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX), + folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE), + ), + folderUnreadCounts = mapOf("INBOX" to 3), + ) + + // The inbox badge announces its exact count for screen readers. + composeTestRule.onNodeWithContentDescription(unreadDescription(3)).assertIsDisplayed() + // Archive has no unread mail, so no badge is rendered for it. + composeTestRule.onNodeWithContentDescription(unreadDescription(1)).assertDoesNotExist() + } + + @Test + fun unreadCountOverTheCap_rendersOverflowBadge() { + setContent( + accounts = listOf(alice), + drawerAccount = alice, + folders = listOf(folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX)), + folderUnreadCounts = mapOf("INBOX" to 150), + ) + + // Over the 99 cap the glyph collapses to "99+" while the semantics still announce the exact + // count; the visible "99+" is cleared from the a11y tree (clearAndSetSemantics), so it is only + // reachable by the content description. + composeTestRule.onNodeWithContentDescription(unreadDescription(150)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.folder_unread_overflow)).assertDoesNotExist() + } + + private fun setContent( + accounts: List, + drawerAccount: Account?, + folders: List, + folderUnreadCounts: Map = emptyMap(), + accountsWithUnread: Set = emptySet(), + selectedAccountId: String? = null, + selectedFolder: String = "INBOX", + onSelectUnifiedInbox: () -> Unit = {}, + onSelectFolder: (String, String) -> Unit = { _, _ -> }, + onSelectDrawerAccount: (String) -> Unit = {}, + ) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ModalDrawerSheet { + FolderDrawer( + accounts = accounts, + drawerAccount = drawerAccount, + folders = folders, + folderUnreadCounts = folderUnreadCounts, + accountsWithUnread = accountsWithUnread, + selectedAccountId = selectedAccountId, + selectedFolder = selectedFolder, + onSelectUnifiedInbox = onSelectUnifiedInbox, + onSelectFolder = onSelectFolder, + onSelectDrawerAccount = onSelectDrawerAccount, + ) + } + } + } + } + + private fun account(id: String, email: String) = Account( + id = id, + email = email, + displayName = email, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun folder( + accountId: String, + fullName: String, + displayName: String, + role: FolderRole, + specialUse: Boolean = false, + ) = Folder(accountId, fullName, displayName, role, selectable = true, specialUse = specialUse) +} diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxScreenJvmTest.kt new file mode 100644 index 0000000..8530f95 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxScreenJvmTest.kt @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.lifecycle.SavedStateHandle +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.sync.Syncer +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.Draft +import org.libremail.domain.model.Folder +import org.libremail.domain.model.FolderRole +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.model.UnreadCount +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented [MailboxScreenTest] (batch 8/9 of umbrella #373): drives + * the real [MailboxScreen] + [MailboxViewModel] over a mocked [MailRepository]/[AccountRepository]/ + * [Syncer] on the JVM via the v2 `createComposeRule()` — no emulator — so [MailboxScreen]'s render + + * interaction code counts toward JaCoCo's JVM-testable surface. Paging 3 is fed on the JVM by handing + * each repository pager a static [PagingData.from] flow (the same technique as [MailboxViewModelTest]); + * no real Room/Paging source stands up. Covers the no-accounts welcome fallback, the populated list + * (sender/subject/snippet, offline badge, unified per-account labels + filter chips, drafts/outbox + * entries), the empty/loading/no-results states, opening/closing search, the multi-select contextual + * action bar (overflow, archive/spam/delete with their confirm dialogs, move picker, and the + * archive-hidden-in-archive branch), and the disambiguated app-bar title. The instrumented + * [MailboxScreenTest] stays as the on-device E2E. See + * [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class MailboxScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int) = context.getString(resId) + private fun string(resId: Int, arg: Int) = context.getString(resId, arg) + + private val account = Account( + id = "imap:a", + email = "a@example.org", + displayName = "A", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + private val bob = account.copy(id = "imap:b", email = "b@example.org", displayName = "B") + + private fun message( + uid: String, + subject: String, + snippet: String = "", + bodyFetched: Boolean = false, + folder: String = "INBOX", + accountId: String = "imap:a", + sender: String = "Sender $uid", + ) = Message( + id = "$accountId:$folder:$uid", + accountId = accountId, + sender = sender, + senderEmail = "s$uid@example.org", + subject = subject, + snippet = snippet, + body = "", + isHtml = false, + timestampMillis = 1_000L, + isRead = true, + isStarred = false, + folder = folder, + inInbox = true, + bodyFetched = bodyFetched, + ) + + // --- Tests ----------------------------------------------------------------------------------- + + @Test + fun noAccounts_showsWelcomeFallback_andInvokesAddAccount() { + var addAccountTapped = false + setContent(accounts = emptyList(), onAddAccount = { addAccountTapped = true }) + + // With no accounts the mailbox falls back to the onboarding welcome invitation. + composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + + assertTrue(addAccountTapped) + } + + @Test + fun populatedInbox_rendersSenderSubjectAndSnippet() { + setContent( + messages = listOf( + message("1", subject = "Lunch plans", snippet = "See you at noon", sender = "Alice"), + ), + ) + waitForText("Lunch plans") + + composeTestRule.onNodeWithText("Alice").assertIsDisplayed() + composeTestRule.onNodeWithText("Lunch plans").assertIsDisplayed() + composeTestRule.onNodeWithText("See you at noon").assertIsDisplayed() + } + + @Test + fun offlineIndicator_showsForCachedMessages() { + setContent(messages = listOf(message("1", subject = "Cached", bodyFetched = true))) + waitForText("Cached") + + composeTestRule.onNodeWithContentDescription(string(R.string.message_available_offline)).assertIsDisplayed() + } + + @Test + fun multipleAccounts_showAccountFilterRow_andPerRowAccountLabels() { + setContent( + accounts = listOf(account, bob), + messages = listOf( + message("1", subject = "From A", accountId = "imap:a"), + message("2", subject = "From B", accountId = "imap:b"), + ), + ) + waitForText("From A") + + // With 2+ accounts on the unified inbox, the all-accounts filter chip heads the chip row. + composeTestRule.onNodeWithText(string(R.string.mailbox_all_accounts)).assertIsDisplayed() + // Each message is tagged with its owning account's address (the unified-inbox account label). + assertTrue(composeTestRule.onAllNodesWithText("a@example.org").fetchSemanticsNodes().isNotEmpty()) + assertTrue(composeTestRule.onAllNodesWithText("b@example.org").fetchSemanticsNodes().isNotEmpty()) + } + + @Test + fun draftsAndOutboxEntries_showAtInbox_andNavigate() { + var openedDrafts = false + var openedOutbox = false + setContent( + messages = listOf(message("1", subject = "Msg")), + draftCount = 2, + outboxCount = 1, + onOpenDrafts = { openedDrafts = true }, + onOpenOutbox = { openedOutbox = true }, + ) + waitForText(string(R.string.drafts_count, 2)) + + composeTestRule.onNodeWithText(string(R.string.drafts_count, 2)).performClick() + assertTrue(openedDrafts) + composeTestRule.onNodeWithText(string(R.string.outbox_count, 1)).performClick() + assertTrue(openedOutbox) + } + + @Test + fun emptyInbox_showsNoMessagesState_onceThePagerSettlesEmpty() { + setContent(pagedOverride = settledEmpty()) + + waitForText(string(R.string.mailbox_empty)) + composeTestRule.onNodeWithText(string(R.string.mailbox_empty)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.mailbox_pull_to_refresh)).assertIsDisplayed() + } + + // Issue #219: the empty state must stay hidden while the pager is still refreshing, so + // "No messages yet" never flashes on return from the reader. This frozen refresh == Loading pager + // never reaches the "settled" gate, so the string can never appear. + @Test + fun emptyInbox_hidesNoMessagesState_whileThePagerIsStillLoading() { + setContent(pagedOverride = loadingEmpty()) + + composeTestRule.onNodeWithText(string(R.string.mailbox_empty)).assertDoesNotExist() + } + + @Test + fun openingSearch_showsHintField_andCloseReturnsToTitle() { + setContent(messages = listOf(message("1", subject = "Msg"))) + waitForText("Msg") + + composeTestRule.onNodeWithContentDescription(string(R.string.search)).performClick() + waitForText(string(R.string.search_hint)) + composeTestRule.onNodeWithText(string(R.string.search_hint)).assertIsDisplayed() + + // The nav icon becomes a Close while searching; tapping it tears the search field down and + // brings the search action back. (The "Mailbox" title is shared with the bottom-nav tab label, + // so key off the search field rather than the ambiguous title text.) + composeTestRule.onNodeWithContentDescription(string(R.string.search_close)).performClick() + composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText(string(R.string.search_hint)).fetchSemanticsNodes().isEmpty() + } + composeTestRule.onNodeWithText(string(R.string.search_hint)).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(string(R.string.search)).assertIsDisplayed() + } + + @Test + fun searchWithNoMatches_showsNoResultsState() { + // Both browse and search pagers are settled-empty, so the visible empty state is driven purely + // by the search flags (StateFlow) flipping the gate from "no messages" to "no results" — no + // reliance on a paging switch delivering different content. + val viewModel = setContent(pagedOverride = settledEmpty(), searchOverride = settledEmpty()) + waitForText(string(R.string.mailbox_empty)) + + // Drive the VM directly (an established pattern in the instrumented test): open search, type a + // query with no matches. + viewModel.openSearch() + viewModel.onSearchQuery("zzz") + + waitForText(string(R.string.search_no_results)) + composeTestRule.onNodeWithText(string(R.string.search_no_results)).assertIsDisplayed() + } + + @Test + fun longPress_entersSelection_andTapTracksTheCount() { + setContent(messages = listOf(message("1", subject = "First"), message("2", subject = "Second"))) + waitForText("First") + + composeTestRule.onNodeWithText("First").performTouchInput { longClick() } + waitForText(string(R.string.cab_selected_count, 1)) + composeTestRule.onNodeWithText(string(R.string.cab_selected_count, 1)).assertIsDisplayed() + + composeTestRule.onNodeWithText("Second").performClick() + waitForText(string(R.string.cab_selected_count, 2)) + composeTestRule.onNodeWithText(string(R.string.cab_selected_count, 2)).assertIsDisplayed() + } + + @Test + fun overflow_showsReplyActions_forSingleSelection() { + val viewModel = setContent(messages = listOf(message("1", subject = "First"))) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + waitForText(string(R.string.cab_selected_count, 1)) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() + + composeTestRule.onNodeWithText(string(R.string.action_reply)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_reply_all)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_forward)).assertIsDisplayed() + } + + @Test + fun overflow_hidesReplyActions_forMultiSelection() { + val viewModel = setContent(messages = listOf(message("1", subject = "First"), message("2", subject = "Second"))) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + viewModel.toggleSelection("imap:a:INBOX:2", "imap:a") + waitForText(string(R.string.cab_selected_count, 2)) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() + + composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist() + } + + @Test + fun archiveIcon_isDirect_andArchivesTheSelection() { + val archived = mutableListOf>() + val repo = mockk(relaxed = true) + coEvery { repo.archive(any()) } answers { + archived += firstArg>() + Result.success(Unit) + } + val viewModel = setContent(messages = listOf(message("1", subject = "First")), repo = repo) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + waitForText(string(R.string.cab_selected_count, 1)) + + // A direct icon button — no trip through the overflow menu. + composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { archived.isNotEmpty() } + assertEquals(listOf("imap:a:INBOX:1"), archived.first()) + } + + @Test + fun spamIcon_confirmsBeforeReporting() { + val spammed = mutableListOf>() + val repo = mockk(relaxed = true) + coEvery { repo.reportSpam(any()) } answers { + spammed += firstArg>() + Result.success(Unit) + } + val viewModel = setContent(messages = listOf(message("1", subject = "First")), repo = repo) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + waitForText(string(R.string.cab_selected_count, 1)) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick() + composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { spammed.isNotEmpty() } + assertEquals(listOf("imap:a:INBOX:1"), spammed.first()) + } + + @Test + fun deleteIcon_confirmsMoveToTrash_thenTrashes() { + val trashed = mutableListOf>() + val repo = mockk(relaxed = true) + coEvery { repo.trash(any()) } answers { + trashed += firstArg>() + Result.success(Unit) + } + val viewModel = setContent(messages = listOf(message("1", subject = "First")), repo = repo) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + waitForText(string(R.string.cab_selected_count, 1)) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).performClick() + composeTestRule.onNodeWithText(string(R.string.confirm_trash_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { trashed.isNotEmpty() } + assertEquals(listOf("imap:a:INBOX:1"), trashed.first()) + } + + @Test + fun cancellingAConfirmation_dismissesWithoutActing() { + val spammed = mutableListOf>() + val repo = mockk(relaxed = true) + coEvery { repo.reportSpam(any()) } answers { + spammed += firstArg>() + Result.success(Unit) + } + val viewModel = setContent(messages = listOf(message("1", subject = "First")), repo = repo) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + waitForText(string(R.string.cab_selected_count, 1)) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick() + composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.cancel)).performClick() + + composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertDoesNotExist() + assertTrue(spammed.isEmpty()) + } + + @Test + fun movePicker_movesSelectionToTheChosenFolder() { + val moved = mutableListOf, String>>() + val repo = mockk(relaxed = true) + coEvery { repo.moveToFolder(any(), any()) } answers { + moved += firstArg>() to secondArg() + Result.success(Unit) + } + val viewModel = setContent( + messages = listOf(message("1", subject = "First")), + folders = mapOf( + "imap:a" to listOf( + Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true), + Folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL, selectable = true), + ), + ), + repo = repo, + ) + waitForText("First") + viewModel.startSelection("imap:a:INBOX:1", "imap:a") + waitForText(string(R.string.cab_selected_count, 1)) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick() + composeTestRule.onNodeWithText(string(R.string.action_move)).performClick() + // The off-screen navigation drawer also lists "Receipts", so scope the tap to the move dialog. + composeTestRule.onNode(hasText("Receipts") and hasAnyAncestor(isDialog())).performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { moved.isNotEmpty() } + assertEquals("Receipts", moved.first().second) + } + + @Test + fun archiveIcon_hides_whileViewingTheArchiveFolder() { + // The archive-hidden branch keys off currentFolderRole (a StateFlow combine of folders + + // selectedFolder), not the paged list, so the selection can be seeded via the VM without + // depending on a paging switch delivering the folder's rows. + val viewModel = setContent( + folders = mapOf( + "imap:a" to listOf( + Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true), + Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true), + ), + ), + ) + viewModel.selectFolder("imap:a", "Archive") + viewModel.startSelection("imap:a:Archive:1", "imap:a") + + // Wait for the ARCHIVE role to propagate: the archive action drops out of the contextual bar + // (Archive/Spam are hidden while already viewing that role's folder). + composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithContentDescription(string(R.string.action_archive)) + .fetchSemanticsNodes().isEmpty() + } + composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed() + } + + @Test + fun appBarTitle_showsTheSelectedFolderFriendlyLabel() { + // The title resolves from selectedFolder + folders (both StateFlow), independent of the paged + // list, so no message rows are needed. + val viewModel = setContent( + folders = mapOf( + "imap:a" to listOf( + Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true), + Folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT, selectable = true), + ), + ), + ) + viewModel.selectFolder("imap:a", "[Gmail]/Sent Mail") + + // Once selected, the friendly "Sent" label renders in BOTH the drawer entry and the app-bar + // title (2 nodes) — proving the title uses the friendly role label, not the raw "Sent Mail". + composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText(string(R.string.folder_sent)).fetchSemanticsNodes().size == 2 + } + composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist() + } + + // --- Harness --------------------------------------------------------------------------------- + + private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + /** + * Builds the real [MailboxViewModel] over a relaxed [MailRepository]/[AccountRepository]/[Syncer] + * and hosts [MailboxScreen] on it. Every mailbox mode is a single paged flow (issues #124, #214); + * each repository pager is mirrored as one static [PagingData.from] page over [messages] (browse + * pagers keep folder-synced `inInbox` rows), exactly as [MailboxViewModelTest] does — no real + * Room/Paging source. [pagedOverride]/[searchOverride] swap in a pre-baked [PagingData] flow (with + * explicit [LoadStates]) to drive the empty/loading/no-results gates deterministically. + */ + private fun setContent( + accounts: List = listOf(account), + messages: List = emptyList(), + folders: Map> = emptyMap(), + draftCount: Int = 0, + outboxCount: Int = 0, + unreadCounts: List = emptyList(), + pagedOverride: Flow>? = null, + searchOverride: Flow>? = null, + repo: MailRepository = mockk(relaxed = true), + syncer: Syncer = mockk(relaxed = true), + onOpenMessage: (String) -> Unit = {}, + onOpenDrafts: () -> Unit = {}, + onOpenOutbox: () -> Unit = {}, + onAddAccount: () -> Unit = {}, + ): MailboxViewModel { + configurePagers(repo, messages, pagedOverride, searchOverride) + every { repo.observeDrafts() } returns MutableStateFlow(List(draftCount) { draft("d$it") }) + every { repo.observeOutbox() } returns MutableStateFlow(List(outboxCount) { outbox("o$it") }) + every { repo.observeUnreadCounts() } returns MutableStateFlow(unreadCounts) + accounts.forEach { acct -> + every { repo.observeFolders(acct.id) } returns MutableStateFlow(folders[acct.id] ?: emptyList()) + } + val accountRepository = mockk(relaxed = true) + every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) + val viewModel = MailboxViewModel(repo, accountRepository, syncer, SavedStateHandle()) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + MailboxScreen( + onOpenMessage = onOpenMessage, + onCompose = {}, + onOpenDrafts = onOpenDrafts, + onOpenOutbox = onOpenOutbox, + onAddAccount = onAddAccount, + onOpenCompose = {}, + onSelectTab = {}, + viewModel = viewModel, + ) + } + } + return viewModel + } + + /** + * Stubs the four repository pagers. Each browse pager mirrors its DAO projection as one static + * [PagingData.from] page over [messages] (folder-synced `inInbox` rows); each search pager matches + * the same columns the DAO's LIKE query does. [pagedOverride]/[searchOverride] instead return a + * pre-baked [PagingData] flow (with explicit [LoadStates]) for the empty/loading/no-results gates. + */ + private fun configurePagers( + repo: MailRepository, + messages: List, + pagedOverride: Flow>?, + searchOverride: Flow>?, + ) { + if (pagedOverride != null) { + every { repo.pagedUnifiedFolderMessages(any()) } returns pagedOverride + every { repo.pagedFolderMessages(any(), any()) } returns pagedOverride + } else { + every { repo.pagedUnifiedFolderMessages(any()) } answers { + val folder = firstArg() + flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox })) + } + every { repo.pagedFolderMessages(any(), any()) } answers { + val accountId = firstArg() + val folder = secondArg() + flowOf( + PagingData.from( + messages.filter { it.accountId == accountId && it.folder == folder && it.inInbox }, + ), + ) + } + } + if (searchOverride != null) { + every { repo.pagedUnifiedSearchMessages(any(), any()) } returns searchOverride + every { repo.pagedFolderSearchMessages(any(), any(), any()) } returns searchOverride + } else { + every { repo.pagedUnifiedSearchMessages(any(), any()) } answers { + val folder = firstArg() + val query = secondArg() + flowOf(PagingData.from(messages.filter { it.folder == folder && it.matchesSearch(query) })) + } + every { repo.pagedFolderSearchMessages(any(), any(), any()) } answers { + val accountId = firstArg() + val folder = secondArg() + val query = thirdArg() + flowOf( + PagingData.from( + messages.filter { it.accountId == accountId && it.folder == folder && it.matchesSearch(query) }, + ), + ) + } + } + } + + private fun draft(id: String) = Draft( + id = id, + accountId = "imap:a", + to = "", + cc = "", + subject = "", + body = "", + updatedAt = 1_000L, + ) + + private fun outbox(id: String) = + OutboxMessage(id = id, to = "", subject = "", body = "", createdAt = 1_000L, lastError = null) + + private fun settledEmpty(): Flow> = flowOf( + PagingData.from( + emptyList(), + LoadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = false), + prepend = LoadState.NotLoading(endOfPaginationReached = true), + append = LoadState.NotLoading(endOfPaginationReached = true), + ), + ), + ) + + private fun loadingEmpty(): Flow> = flowOf( + PagingData.from( + emptyList(), + LoadStates( + refresh = LoadState.Loading, + prepend = LoadState.NotLoading(endOfPaginationReached = false), + append = LoadState.NotLoading(endOfPaginationReached = false), + ), + ), + ) + + private companion object { + const val TIMEOUT_MS = 5_000L + } +} + +/** Mirrors the paged-search DAO queries' columns so the stubbed search pagers filter like production. */ +private fun Message.matchesSearch(query: String): Boolean = sender.contains(query, ignoreCase = true) || + senderEmail.contains(query, ignoreCase = true) || + subject.contains(query, ignoreCase = true) || + snippet.contains(query, ignoreCase = true) -- 2.47.3