test(compose): Robolectric JVM tests — mailbox screen + folder drawer (#383)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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*",
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user