Merge pull request #416 from JMR-dev/test-383-robolectric-mailbox

test(compose): Robolectric JVM tests — mailbox screen + folder drawer (#383)
This commit was merged in pull request #416.
This commit is contained in:
Jason Ross
2026-07-07 01:11:42 -05:00
committed by GitHub
3 changed files with 870 additions and 2 deletions
+2 -2
View File
@@ -323,8 +323,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 converted to a Robolectric JVM Compose test (#381) — now JVM-covered. Its HTML body
@@ -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<String, String>? = 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<Account>,
drawerAccount: Account?,
folders: List<Folder>,
folderUnreadCounts: Map<String, Int> = emptyMap(),
accountsWithUnread: Set<String> = 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)
}
@@ -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<List<String>>()
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.archive(any()) } answers {
archived += firstArg<List<String>>()
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<List<String>>()
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.reportSpam(any()) } answers {
spammed += firstArg<List<String>>()
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<List<String>>()
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.trash(any()) } answers {
trashed += firstArg<List<String>>()
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<List<String>>()
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.reportSpam(any()) } answers {
spammed += firstArg<List<String>>()
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<Pair<List<String>, String>>()
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.moveToFolder(any(), any()) } answers {
moved += firstArg<List<String>>() to secondArg<String>()
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<Account> = listOf(account),
messages: List<Message> = emptyList(),
folders: Map<String, List<Folder>> = emptyMap(),
draftCount: Int = 0,
outboxCount: Int = 0,
unreadCounts: List<UnreadCount> = emptyList(),
pagedOverride: Flow<PagingData<Message>>? = null,
searchOverride: Flow<PagingData<Message>>? = 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<AccountRepository>(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<Message>,
pagedOverride: Flow<PagingData<Message>>?,
searchOverride: Flow<PagingData<Message>>?,
) {
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<String>()
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
}
every { repo.pagedFolderMessages(any(), any()) } answers {
val accountId = firstArg<String>()
val folder = secondArg<String>()
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<String>()
val query = secondArg<String>()
flowOf(PagingData.from(messages.filter { it.folder == folder && it.matchesSearch(query) }))
}
every { repo.pagedFolderSearchMessages(any(), any(), any()) } answers {
val accountId = firstArg<String>()
val folder = secondArg<String>()
val query = thirdArg<String>()
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<PagingData<Message>> = flowOf(
PagingData.from(
emptyList<Message>(),
LoadStates(
refresh = LoadState.NotLoading(endOfPaginationReached = false),
prepend = LoadState.NotLoading(endOfPaginationReached = true),
append = LoadState.NotLoading(endOfPaginationReached = true),
),
),
)
private fun loadingEmpty(): Flow<PagingData<Message>> = flowOf(
PagingData.from(
emptyList<Message>(),
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)