Merge branch 'main' into test-383-robolectric-mailbox

This commit is contained in:
Jason Ross
2026-07-07 00:53:06 -05:00
committed by GitHub
8 changed files with 1760 additions and 7 deletions
+10 -7
View File
@@ -315,7 +315,8 @@ val jacocoNonJvmTestableSurface = listOf(
"**/LibreMailApp*",
// AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM
// Compose tests (#378) — now JVM-covered.
"**/ComposeScreen*",
// ComposeScreen (the email editor) converted to a Robolectric JVM Compose test (#382) — now
// JVM-covered.
// ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to
// Robolectric JVM Compose tests (#376) — now JVM-covered.
// DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests
@@ -326,12 +327,14 @@ val jacocoNonJvmTestableSurface = listOf(
// 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*",
"**/AccountSettingsScreen*",
"**/SettingsScreen*",
"**/SettingsComponents*",
"**/SignatureEditScreen*",
"**/SignaturesScreen*",
// ReaderScreen converted to a Robolectric JVM Compose test (#381) — now JVM-covered. Its HTML body
// renders through HtmlBody, a hardened WebView that Robolectric can only present as a non-rendering
// shadow, so ReaderScreenJvmTest asserts the chrome (top bar, star/delete/reply actions, attachment
// accordion) and the loading/plain-text/empty/error/remote-images-banner branches — never the
// WebView's rendered HTML. HtmlBody.kt stays in scope covered by HtmlBodyTest/InlineImageResolverTest.
// SettingsScreen (+ ContactAutocompleteRow), AccountSettingsScreen, SettingsComponents (SwitchRow/
// ClickRow/RadioRow/RetentionSection), SignaturesScreen & SignatureEditScreen converted to
// Robolectric JVM Compose tests (#380) — now JVM-covered.
// CacheEncryptionGate.kt (issue #359/#367 fail-closed encryption gate) is pure render: the gate
// composable, its blank cover, the error screen, and the ephemeral report-review screen — no plain
// top-level logic. Spelled out to "...GateKt*" (the file's compiled facade class), NOT the bare
@@ -0,0 +1,389 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import android.content.Context
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.performTextInput
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
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.contacts.ContactSuggestion
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.OutgoingAttachment
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 [ComposeScreenTest] (batch 7/9 of umbrella #373): drives
* the email-editor [ComposeScreen] on the JVM via the v2 `createComposeRule()` — no emulator — so its
* render + interaction code counts toward JaCoCo's JVM-testable surface (this file is dropped from
* `jacocoNonJvmTestableSurface`). The instrumented [ComposeScreenTest] stays as the on-device E2E.
*
* Because [ComposeViewModel] is large (7 collaborators, several `Context`/Room-backed) and its logic
* is exercised elsewhere, it is **mocked** here (mirroring [org.libremail.ui.accountsetup]'s
* `AccountPickerScreenJvmTest` / `ManualSetupScreenJvmTest`): the three flows the screen reads
* (`state`, `accounts`, `finished`) are stubbed so every render/state branch can be injected directly,
* and interactions are asserted by verifying they forward to the view model. The embedded
* [RichTextBodyField] (from `RichTextEditor.kt`, itself unit-tested by `RichTextEditorTest`) renders
* live; the toolbar's accessibility labels and its body-change plumbing are covered here too.
*
* A RESUMED [LocalLifecycleOwner] is provided so `collectAsStateWithLifecycle` collects; the same
* owner backs [LocalOnBackPressedDispatcherOwner] for the screen's `BackHandler`, and a no-op
* [ActivityResultRegistryOwner] lets the attachment/inline-image `rememberLauncherForActivityResult`
* register without surfacing a real picker. `@GraphicsMode(NATIVE)` + `@Config(sdk = [36])` match the
* rest of the suite; the tall `qualifiers` keeps the whole scrolling form on-screen.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// A tall display so the whole scrolling compose form — From/To/Subject/attachments/body + toolbar —
// fits Robolectric's small default viewport, keeping controls on-screen for the assertions below.
@Config(sdk = [36], qualifiers = "+w411dp-h2000dp")
class ComposeScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
private val account = Account(
id = "imap:me@example.com",
email = "me@example.com",
displayName = "Me",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
)
private val otherAccount = Account(
id = "imap:other@example.com",
email = "other@example.com",
displayName = "Other",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
)
/** RESUMED owner (also the back-press dispatcher owner) so state collects and `BackHandler` composes. */
private val owner = object : OnBackPressedDispatcherOwner {
private val registry =
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
private val dispatcher = OnBackPressedDispatcher()
override val lifecycle: Lifecycle get() = registry
override val onBackPressedDispatcher: OnBackPressedDispatcher get() = dispatcher
}
/**
* A no-op registry so the screen's attachment / inline-image `rememberLauncherForActivityResult`
* can register (and, if tapped, launch) on the JVM without surfacing a real document picker.
*/
private val noopRegistryOwner = object : ActivityResultRegistryOwner {
override val activityResultRegistry = object : ActivityResultRegistry() {
override fun <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
// Intentionally never dispatch a result: the launch is a no-op in this JVM test.
}
}
}
private fun viewModel(
state: ComposeUiState = ComposeUiState(),
accounts: List<Account> = listOf(account),
finished: Flow<Unit> = emptyFlow(),
): ComposeViewModel {
val vm = mockk<ComposeViewModel>(relaxed = true)
every { vm.state } returns MutableStateFlow(state)
every { vm.accounts } returns MutableStateFlow(accounts)
every { vm.finished } returns finished
return vm
}
private fun setContent(viewModel: ComposeViewModel, onBack: () -> Unit = {}) {
composeTestRule.setContent {
CompositionLocalProvider(
LocalLifecycleOwner provides owner,
LocalOnBackPressedDispatcherOwner provides owner,
LocalActivityResultRegistryOwner provides noopRegistryOwner,
) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ComposeScreen(onBack = onBack, viewModel = viewModel)
}
}
}
}
private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
/** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */
private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction())
@Test
fun initialRender_showsRecipientSubjectBodyAttachAndSingleAccountFrom() {
setContent(viewModel())
composeTestRule.onNodeWithText(string(R.string.compose_to)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.compose_subject)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.compose_body)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.compose_attach)).assertIsDisplayed()
// Exactly one account → the From row shows the address as plain text (no dropdown affordance).
composeTestRule.onNodeWithText(account.email).assertIsDisplayed()
// An empty recipient leaves Send disabled.
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled()
}
@Test
fun recipientEntered_enablesSend_andTapInvokesSend() {
val vm = viewModel(ComposeUiState(to = "you@example.com"))
setContent(vm)
composeTestRule.onNodeWithContentDescription(string(R.string.action_send))
.assertIsEnabled()
.performClick()
verify { vm.send() }
}
@Test
fun sendingState_showsSpinner_andDisablesSend() {
setContent(viewModel(ComposeUiState(to = "you@example.com", sending = true)))
// The blocking overlay spinner renders...
composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
// ...and Send is disabled while a send is in flight, even with a valid recipient.
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled()
}
@Test
fun tappingBack_invokesOnExit() {
val vm = viewModel()
setContent(vm)
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick()
verify { vm.onExit() }
}
@Test
fun typingRecipientSubjectAndBody_forwardsToTheViewModel() {
val vm = viewModel()
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_subject)).performTextInput("Hello")
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Body text")
verify { vm.onToChange(any()) }
verify { vm.onSubjectChange(any()) }
verify { vm.onBodyChange(any(), any()) }
}
@Test
fun suggestions_render_andTappingOneForwardsToPickSuggestion() {
val suggestion = ContactSuggestion(name = "Alice Example", email = "alice@example.org")
val vm = viewModel(ComposeUiState(to = "al", suggestions = listOf(suggestion)))
setContent(vm)
composeTestRule.onNodeWithText("Alice Example").assertIsDisplayed()
composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed()
composeTestRule.onNodeWithText("Alice Example").performClick()
verify { vm.pickSuggestion(suggestion) }
}
@Test
fun attachmentPrompt_whenFlagged_showsDialog_andYesInvokesAttachInstead() {
val vm = viewModel(ComposeUiState(to = "you@example.com", showAttachmentPrompt = true))
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_yes)).performClick()
verify { vm.attachInstead() }
}
@Test
fun attachmentPrompt_noInvokesSendAnyway() {
val vm = viewModel(ComposeUiState(to = "you@example.com", showAttachmentPrompt = true))
setContent(vm)
composeTestRule.onNodeWithText(string(R.string.action_no)).performClick()
verify { vm.sendAnyway() }
}
@Test
fun noAccounts_showsNoAccountLabel() {
setContent(viewModel(accounts = emptyList()))
composeTestRule.onNodeWithText(string(R.string.compose_no_account)).assertIsDisplayed()
}
@Test
fun multipleAccounts_showFromDropdown_andSelectingOneForwards() {
val vm = viewModel(
state = ComposeUiState(fromAccountId = account.id),
accounts = listOf(account, otherAccount),
)
setContent(vm)
// With >1 account the From row is a dropdown anchor showing the selected address.
composeTestRule.onNodeWithText(account.email).assertIsDisplayed().performClick()
// The opened menu lists the other account; picking it forwards the id to the view model.
composeTestRule.onNodeWithText(otherAccount.email).performClick()
verify { vm.selectFrom(otherAccount.id) }
}
@Test
fun attachments_normalRendersChip_inlineHidden_andTappingRemoves() {
val normal = OutgoingAttachment(uri = "content://doc/1", name = "report.pdf")
val inline = OutgoingAttachment(
uri = "content://img/1",
name = "inline-image.png",
contentId = "cid-1",
isInline = true,
)
val vm = viewModel(ComposeUiState(attachments = listOf(normal, inline)))
setContent(vm)
// Regular attachments show as removable chips; inline images live in the body, not as chips.
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText("inline-image.png").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(string(R.string.compose_attachment_remove)).assertIsDisplayed()
composeTestRule.onNodeWithText("report.pdf").performClick()
verify { vm.removeAttachment("content://doc/1") }
}
@Test
fun ccBcc_startCollapsed_expandViaLink_andForwardEdits() {
val vm = viewModel()
setContent(vm)
// Collapsed: Cc exists only as a link, not as an editable field.
editableField(R.string.compose_cc).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.compose_cc)).performClick()
editableField(R.string.compose_cc).performTextInput("cc@example.com")
verify { vm.onCcChange(any()) }
}
@Test
fun prefilledCc_startsExpandedWithItsValue() {
setContent(viewModel(ComposeUiState(cc = "cc@example.com")))
editableField(R.string.compose_cc).assertIsDisplayed()
composeTestRule.onNodeWithText("cc@example.com").assertIsDisplayed()
}
@Test
fun formattingToolbar_buttonsCarryOnClickLabelsForAccessibility() {
setContent(viewModel())
// Every toolbar button (see FormattingToolbar in RichTextEditor.kt) is a plain clickable Box with
// a bare glyph Text as its only visible content, so TalkBack relies entirely on the click action's
// label (there is no separate contentDescription) to announce what the button does.
val buttons = listOf(
"B" to R.string.format_bold,
"I" to R.string.format_italic,
"U" to R.string.format_underline,
"•" to R.string.format_bullet_list,
"1." to R.string.format_numbered_list,
"❝" to R.string.format_quote,
"🔗" to R.string.format_link,
)
buttons.forEach { (glyph, descriptionRes) ->
val config = composeTestRule.onNodeWithText(glyph).fetchSemanticsNode().config
val clickLabel = if (config.contains(SemanticsActions.OnClick)) {
config[SemanticsActions.OnClick].label
} else {
null
}
val message = "toolbar button \"$glyph\" is missing its accessibility label"
assertEquals(message, string(descriptionRes), clickLabel)
}
}
@Test
fun tappingAToolbarButton_flowsABodyChangeToTheViewModel() {
val vm = viewModel()
setContent(vm)
// The bullet button applies a block marker to the caret's line and re-emits the body — proving a
// toolbar tap reaches ComposeViewModel.onBodyChange (both plaintext + HTML forms).
composeTestRule.onNodeWithText("•").performClick()
verify { vm.onBodyChange(any(), any()) }
}
@Test
fun finishedSignal_invokesOnBack() {
var backed = false
setContent(viewModel(finished = flowOf(Unit)), onBack = { backed = true })
composeTestRule.waitUntil(TIMEOUT_MS) { backed }
assertTrue(backed)
}
@Test
fun errorState_isSurfacedAsASnackbar() {
setContent(viewModel(ComposeUiState(error = "Could not send")))
waitForText("Could not send")
composeTestRule.onNodeWithText("Could not send").assertIsDisplayed()
}
private companion object {
const val TIMEOUT_MS = 5_000L
}
}
@@ -0,0 +1,366 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
import android.content.Context
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
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 reader screen's chrome (issue #381, umbrella #373): drives the real
* [ReaderViewModel] over a mocked [MailRepository] / [SettingsRepository] on the JVM under
* [RobolectricTestRunner] via the v2 `createComposeRule()` — no emulator — so [ReaderScreen] counts
* toward JaCoCo's JVM-testable surface. Mirrors the instrumented `ReaderScreenTest` for the
* interactions (attachments accordion, reply/reply-all/forward, star, delete) and additionally
* exercises the loading / plain-text / empty / error branches and the remote-images banner.
*
* **WebView caveat.** The HTML body renders through [HtmlBody] — a hardened `WebView` embedded via
* `AndroidView`. Under Robolectric the `WebView` is a non-rendering *shadow*, so these tests never
* assert WebView-rendered HTML. The remote-images banner (which only appears for an HTML message) is
* driven with an HTML message whose body is **blank**: that hits the `isHtml && !loadRemoteImages`
* banner branch and the blank-body placeholder branch *without* invoking [HtmlBody] at all, so no
* WebView shadow is instantiated. `HtmlBody.kt` therefore stays out of this batch — its logic
* (`cidKey`/`resolveInlineImage`/`wrapHtml`/`toCssHex`) is already JVM-covered by `HtmlBodyTest` and
* `InlineImageResolverTest`, and its `WebView` render is not meaningfully JVM-testable. The
* instrumented `ReaderScreenTest` stays as the on-device E2E. See
* [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern.
*
* `@Config(sdk = [36])` because compileSdk/targetSdk 37 (preview) has no Robolectric 4.16 sandbox;
* `@GraphicsMode(NATIVE)` is required for Compose to render under Robolectric.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36])
class ReaderScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
private fun seeMore(extraCount: Int): String =
context.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount)
private val messageId = "imap:a:INBOX:1"
private val plainMessage = Message(
id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
subject = "Subject line", snippet = "", body = "Hello plain body", isHtml = false,
timestampMillis = 1_000L, isRead = true, isStarred = false,
)
private fun attachment(partIndex: Int, filename: String, sizeBytes: Long = 1_000L) =
Attachment(messageId, partIndex, filename, "application/pdf", sizeBytes)
/** The draft id the reader last asked to open compose on (via OpenCompose), or null. */
private var openedDraftId: String? = null
/** Set when the reader invokes onBack (after a delete, or its own back button). */
private var backInvoked = false
/**
* A relaxed [MailRepository] whose reads return [message] (or [openResult]) plus the given
* [attachments] / [downloadedParts]. Stubs the value-class-returning suspend functions explicitly
* — MockK can't synthesize a relaxed [Result] — so `buildReplyDraft` yields `draft-<id>` (matching
* the instrumented fake) and `downloadAttachment` fails (the failure path is what's driven here;
* the success path launches a FileProvider intent that isn't meaningful on the JVM).
*/
private fun mailRepo(
openResult: Result<Message> = Result.success(plainMessage),
attachments: List<Attachment> = emptyList(),
downloadedParts: Set<Int> = emptySet(),
): MailRepository {
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.openMessage(messageId) } returns openResult
every { repo.observeAttachments(messageId) } returns flowOf(attachments)
coEvery { repo.downloadedAttachmentParts(messageId) } returns downloadedParts
coEvery { repo.inlineImages(messageId) } returns emptyList()
coEvery { repo.setStarred(any(), any()) } returns Result.success(Unit)
coEvery { repo.deleteMessage(any()) } returns Result.success(Unit)
coEvery { repo.buildReplyDraft(eq(messageId), any()) } returns Result.success("draft-$messageId")
coEvery { repo.downloadAttachment(any(), any()) } returns Result.failure(RuntimeException("no network"))
return repo
}
private fun settingsRepo(loadRemoteImages: Boolean = false): SettingsRepository {
val settings = mockk<SettingsRepository>()
every { settings.settings } returns flowOf(AppSettings(loadRemoteImages = loadRemoteImages))
return settings
}
private fun setContent(repo: MailRepository = mailRepo(), settings: SettingsRepository = settingsRepo()) {
val viewModel = ReaderViewModel(
SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
repo,
settings,
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReaderScreen(
onBack = { backInvoked = true },
onOpenCompose = { openedDraftId = it },
viewModel = viewModel,
)
}
}
}
/** Waits until the message has loaded — the Reply action appears once `state.message` is non-null. */
private fun awaitLoaded() {
composeTestRule.waitUntil(TIMEOUT_MS) {
composeTestRule.onAllNodesWithText(string(R.string.reader_reply)).fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun loading_showsProgressIndicator_andNoActions() {
// openMessage never returns, so the initial loading=true state stays and the spinner shows.
val repo = mockk<MailRepository>(relaxed = true)
coEvery { repo.openMessage(messageId) } coAnswers { awaitCancellation() }
every { repo.observeAttachments(messageId) } returns flowOf(emptyList())
coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
setContent(repo)
composeTestRule
.onNode(SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo))
.assertExists()
// No message yet, so the toolbar actions aren't rendered.
composeTestRule.onNodeWithText(string(R.string.reader_reply)).assertDoesNotExist()
}
@Test
fun plainTextMessage_rendersTitleHeaderAndBody() {
setContent()
awaitLoaded()
composeTestRule.onNodeWithText(string(R.string.title_reader)).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).assertIsDisplayed()
composeTestRule.onNodeWithText("Subject line").assertIsDisplayed()
composeTestRule.onNodeWithText("Sender").assertIsDisplayed()
composeTestRule.onNodeWithText("s@example.org").assertIsDisplayed()
composeTestRule.onNodeWithText("Hello plain body").assertIsDisplayed()
}
@Test
fun blankBody_showsNoTextPlaceholder() {
setContent(mailRepo(openResult = Result.success(plainMessage.copy(body = ""))))
awaitLoaded()
composeTestRule.onNodeWithText(string(R.string.reader_empty)).assertIsDisplayed()
}
@Test
fun loadFailure_showsErrorText() {
setContent(mailRepo(openResult = Result.failure(RuntimeException("Load failed"))))
// The message never loads, so awaitLoaded() would time out; wait for the error branch instead.
composeTestRule.waitUntil(TIMEOUT_MS) {
composeTestRule.onAllNodesWithText("Load failed").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Load failed").assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.reader_reply)).assertDoesNotExist()
}
@Test
fun tappingStar_togglesStarredViaRepository() {
val repo = mailRepo()
setContent(repo)
awaitLoaded()
composeTestRule.onNodeWithContentDescription(string(R.string.reader_star)).performClick()
composeTestRule.waitForIdle()
// The starting message is unstarred, so the tap flips it to starred.
coVerify(exactly = 1) { repo.setStarred(messageId, true) }
}
@Test
fun tappingDelete_deletesAndInvokesOnBack() {
val repo = mailRepo()
setContent(repo)
awaitLoaded()
composeTestRule.onNodeWithContentDescription(string(R.string.reader_delete)).performClick()
composeTestRule.waitUntil(TIMEOUT_MS) { backInvoked }
coVerify(exactly = 1) { repo.deleteMessage(messageId) }
}
@Test
fun tappingReply_buildsReplyDraftAndOpensCompose() {
val repo = mailRepo()
setContent(repo)
awaitLoaded()
composeTestRule.onNodeWithText(string(R.string.reader_reply)).performClick()
composeTestRule.waitUntil(TIMEOUT_MS) { openedDraftId != null }
// #303: reply goes through buildReplyDraft (quoted original + signature), then opens that draft.
assertEquals("draft-$messageId", openedDraftId)
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.REPLY) }
}
@Test
fun replyAll_viaOverflow_buildsReplyAllDraft() {
val repo = mailRepo()
setContent(repo)
awaitLoaded()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).performClick()
composeTestRule.waitUntil(TIMEOUT_MS) { openedDraftId != null }
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.REPLY_ALL) }
}
@Test
fun forward_viaOverflow_buildsForwardDraft() {
val repo = mailRepo()
setContent(repo)
awaitLoaded()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_forward)).performClick()
composeTestRule.waitUntil(TIMEOUT_MS) { openedDraftId != null }
coVerify(exactly = 1) { repo.buildReplyDraft(messageId, ReplyMode.FORWARD) }
}
@Test
fun singleAttachment_showsRowWithoutAccordion() {
// 2_000_000 bytes exercises formatSize's MB branch ("1.9 MB").
setContent(mailRepo(attachments = listOf(attachment(0, "solo.pdf", sizeBytes = 2_000_000L))))
awaitLoaded()
composeTestRule.onNodeWithText(string(R.string.attachments_title)).assertIsDisplayed()
composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText("1.9 MB").assertIsDisplayed()
// A lone attachment has no "See more" accordion.
composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist()
}
@Test
fun multipleAttachments_collapseExtrasUntilExpanded() {
// "noext" (no extension, 0 bytes) exercises fileExtension's "FILE" fallback and formatSize's
// empty branch; "three.pdf" at 2048 bytes exercises the KB branch once revealed.
setContent(
mailRepo(
attachments = listOf(
attachment(0, "one.pdf", sizeBytes = 1_000L),
attachment(1, "noext", sizeBytes = 0L),
attachment(2, "three.pdf", sizeBytes = 2_048L),
),
),
)
awaitLoaded()
// First row shown; the two extras hide behind the collapsed accordion.
composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed()
composeTestRule.onNodeWithText("noext").assertDoesNotExist()
composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist()
// Tapping the control reveals the remaining rows.
composeTestRule.onNodeWithText(seeMore(2)).performClick()
composeTestRule.waitUntil(TIMEOUT_MS) {
composeTestRule.onAllNodesWithText("three.pdf").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("noext").assertIsDisplayed()
composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed()
}
@Test
fun downloadedAttachment_showsDownloadedIndicator() {
// 2048 bytes exercises formatSize's KB branch ("2 KB").
setContent(
mailRepo(
attachments = listOf(attachment(0, "report.pdf", sizeBytes = 2_048L)),
downloadedParts = setOf(0),
),
)
awaitLoaded()
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText("2 KB").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
}
@Test
fun tappingAttachment_whoseDownloadFails_showsSnackbar() {
setContent(mailRepo(attachments = listOf(attachment(0, "report.pdf"))))
awaitLoaded()
composeTestRule.onNodeWithText("report.pdf").performClick()
val failed = string(R.string.attachment_download_failed).format("report.pdf")
composeTestRule.waitUntil(TIMEOUT_MS) {
composeTestRule.onAllNodesWithText(failed).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText(failed).assertIsDisplayed()
}
@Test
fun htmlMessageBlankBody_showsRemoteImagesBanner_thenHidesOnShowImages() {
// An HTML message with a blank body hits the `isHtml && !loadRemoteImages` banner branch and the
// blank-body placeholder — without invoking HtmlBody's (non-rendering) WebView shadow.
setContent(mailRepo(openResult = Result.success(plainMessage.copy(isHtml = true, body = ""))))
awaitLoaded()
composeTestRule.onNodeWithText(string(R.string.reader_images_blocked)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.reader_empty)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.reader_show_images)).performClick()
composeTestRule.waitForIdle()
// Opting in clears the banner.
composeTestRule.onNodeWithText(string(R.string.reader_images_blocked)).assertDoesNotExist()
}
@Test
fun htmlMessage_withLoadRemoteImagesPreference_showsNoBanner() {
// The "load remote images by default" preference flips state.loadRemoteImages on init, so the
// banner never appears.
setContent(
repo = mailRepo(openResult = Result.success(plainMessage.copy(isHtml = true, body = ""))),
settings = settingsRepo(loadRemoteImages = true),
)
awaitLoaded()
composeTestRule.onNodeWithText(string(R.string.reader_images_blocked)).assertDoesNotExist()
}
private companion object {
const val TIMEOUT_MS = 5_000L
}
}
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.performScrollTo
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
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.AccountSettings
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 Compose tests for [AccountSettingsScreen] (umbrella #373, batch #380). Drives the
* per-account settings screen on the JVM under [RobolectricTestRunner] via the v2 `createComposeRule()`
* — no emulator — with a MockK [AccountSettingsViewModel] (its own logic is covered by
* `AccountSettingsViewModelTest`) so `AccountSettingsScreen.kt` counts toward JaCoCo's JVM-testable
* surface: the email-vs-fallback title branch, the default-account/signature/notification switches, the
* signatures-summary count branch, the per-account retention (with "use default"), and the remove-account
* row. The instrumented `AccountSettingsScreenTest` 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 AccountSettingsScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val accountId = "imap:me@example.com"
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
// collectAsStateWithLifecycle needs a LocalLifecycleOwner; the v2 rule hosts no Activity.
private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
private fun mockViewModel(
account: Account? = Account.outlook("me@example.com"),
settings: AccountSettings = AccountSettings(accountId),
signatureCount: Int = 0,
defaultSignatureName: String = "",
isDefaultAccount: Boolean = false,
removing: Boolean = false,
): AccountSettingsViewModel {
val viewModel = mockk<AccountSettingsViewModel>(relaxed = true)
every { viewModel.account } returns MutableStateFlow(account)
every { viewModel.settings } returns MutableStateFlow(settings)
every { viewModel.signatureCount } returns MutableStateFlow(signatureCount)
every { viewModel.defaultSignatureName } returns MutableStateFlow(defaultSignatureName)
every { viewModel.isDefaultAccount } returns MutableStateFlow(isDefaultAccount)
every { viewModel.removing } returns MutableStateFlow(removing)
return viewModel
}
private fun setContent(
viewModel: AccountSettingsViewModel,
onBack: () -> Unit = {},
onManageSignatures: () -> Unit = {},
) {
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AccountSettingsScreen(
onBack = onBack,
onManageSignatures = onManageSignatures,
viewModel = viewModel,
)
}
}
}
}
@Test
fun nullAccount_showsFallbackTitle() {
setContent(mockViewModel(account = null))
composeTestRule.onNodeWithText(string(R.string.settings_account_title)).assertIsDisplayed()
}
@Test
fun presentAccount_showsEmailAsTitle() {
setContent(mockViewModel(account = Account.outlook("me@example.com")))
composeTestRule.onNodeWithText("me@example.com").assertIsDisplayed()
}
@Test
fun noSignatures_showsNoneSummary() {
setContent(mockViewModel(signatureCount = 0))
composeTestRule.onNodeWithText(string(R.string.settings_signatures_summary_none))
.performScrollTo().assertIsDisplayed()
}
@Test
fun withSignatures_showsManageRow() {
setContent(mockViewModel(signatureCount = 2, defaultSignatureName = "Work"))
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage))
.performScrollTo().assertIsDisplayed()
}
@Test
fun tappingManageSignatures_invokesCallback() {
var managed = false
setContent(
mockViewModel(signatureCount = 1, defaultSignatureName = "Work"),
onManageSignatures = { managed = true },
)
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performScrollTo().performClick()
assertTrue(managed)
}
@Test
fun togglingSignatureEnabled_callsViewModel() {
val viewModel = mockViewModel(settings = AccountSettings(accountId, signatureEnabled = true))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_signature_enable)).performScrollTo().performClick()
verify { viewModel.setSignatureEnabled(false) }
}
@Test
fun togglingAccountNotifications_callsViewModel() {
val viewModel = mockViewModel(settings = AccountSettings(accountId, notificationsEnabled = true))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_account_new_mail)).performScrollTo().performClick()
verify { viewModel.setNotificationsEnabled(false) }
}
@Test
fun togglingSetDefaultAccount_callsViewModel() {
val viewModel = mockViewModel(isDefaultAccount = false)
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_account_set_default)).performScrollTo().performClick()
verify { viewModel.setDefaultAccount(true) }
}
@Test
fun perAccountRetention_offersUseDefault() {
setContent(mockViewModel(settings = AccountSettings(accountId, retentionCount = null)))
// includeUseDefault = true on the per-account screen, so "use the global default" is offered in
// both the message-count group and the age group.
composeTestRule.onAllNodesWithText(string(R.string.retention_use_default)).assertCountEquals(2)
}
@Test
fun tappingRemoveAccount_callsViewModel() {
val viewModel = mockViewModel(removing = false)
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.account_remove)).performScrollTo().performClick()
verify { viewModel.removeAccount(any()) }
}
@Test
fun tappingBack_invokesCallback() {
var backInvoked = false
setContent(mockViewModel(), onBack = { backInvoked = true })
composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick()
assertTrue(backInvoked)
}
}
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.isNotEnabled
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
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.ui.theme.LibreMailTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Robolectric JVM Compose tests for the shared settings row/section composables in
* [SettingsComponents] (umbrella #373, batch #380): [SectionHeader], [SwitchRow], [ClickRow],
* [RadioRow] and the device-only [RetentionSection]. Each stateless piece is driven directly and its
* render + interaction/branch logic asserted deterministically on the JVM under [RobolectricTestRunner]
* via the v2 `createComposeRule()` — no emulator — so `SettingsComponents.kt` counts toward JaCoCo's
* JVM-testable surface. The instrumented `SettingsScreenTest`/`AccountSettingsScreenTest` stay 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 SettingsComponentsJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
/** Renders [content] inside the app theme and a scroll container so tall sections can `performScrollTo`. */
private fun setContent(content: @Composable () -> Unit) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
Column(Modifier.verticalScroll(rememberScrollState())) { content() }
}
}
}
@Test
fun sectionHeader_rendersItsText() {
setContent { SectionHeader("Downloading") }
composeTestRule.onNodeWithText("Downloading").assertIsDisplayed()
}
@Test
fun switchRow_offWithSubtitle_rowClickTogglesOn() {
var reported: Boolean? = null
setContent {
SwitchRow(
title = "Dynamic color",
checked = false,
onCheckedChange = { reported = it },
subtitle = "Use wallpaper",
)
}
composeTestRule.onNodeWithText("Dynamic color").assertIsDisplayed()
composeTestRule.onNodeWithText("Use wallpaper").assertIsDisplayed()
composeTestRule.onNodeWithText("Dynamic color").performClick()
assertEquals(true, reported)
}
@Test
fun switchRow_onWithoutSubtitle_rowClickTogglesOff() {
var reported: Boolean? = null
setContent { SwitchRow(title = "Push", checked = true, onCheckedChange = { reported = it }) }
composeTestRule.onNodeWithText("Push").performClick()
assertEquals(false, reported)
}
@Test
fun clickRow_withSubtitle_invokesOnClick() {
var clicked = false
setContent { ClickRow(title = "Add account", onClick = { clicked = true }, subtitle = "Another mailbox") }
composeTestRule.onNodeWithText("Another mailbox").assertIsDisplayed()
composeTestRule.onNodeWithText("Add account").performClick()
assertTrue(clicked)
}
@Test
fun clickRow_disabled_rendersAsNotEnabled() {
setContent { ClickRow(title = "Remove", onClick = {}, enabled = false) }
// enabled = false marks the row disabled (no click semantics action) — the `enabled` branch.
composeTestRule.onNodeWithText("Remove").assert(isNotEnabled())
}
@Test
fun radioRow_rendersSubtitle_andClickInvokesOnClick() {
var clicked = false
setContent {
RadioRow(title = "On demand", subtitle = "Manual only", selected = false, onClick = { clicked = true })
}
composeTestRule.onNodeWithText("Manual only").assertIsDisplayed()
composeTestRule.onNodeWithText("On demand").performClick()
assertTrue(clicked)
}
@Test
fun radioRow_selectedWithoutSubtitle_renders() {
setContent { RadioRow(title = "Always", selected = true, onClick = {}) }
composeTestRule.onNodeWithText("Always").assertIsDisplayed()
}
@Test
fun retentionSection_globalScreen_hidesUseDefault_andShowsBothGroups() {
setContent {
RetentionSection(count = 0, months = 0, includeUseDefault = false, onCountChange = {}, onMonthsChange = {})
}
composeTestRule.onNodeWithText(string(R.string.settings_retention)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.retention_count_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.retention_age_title)).assertIsDisplayed()
// The global screen omits the "use the global default" option in both groups.
composeTestRule.onAllNodesWithText(string(R.string.retention_use_default)).assertCountEquals(0)
}
@Test
fun retentionSection_accountScreen_showsUseDefaultInBothGroups_andSelectingItReportsNull() {
var reportedCount: Int? = -1
setContent {
RetentionSection(
count = 500,
months = 6,
includeUseDefault = true,
onCountChange = { reportedCount = it },
onMonthsChange = {},
)
}
// The per-account screen adds "use the global default" to the count group and the age group.
composeTestRule.onAllNodesWithText(string(R.string.retention_use_default)).assertCountEquals(2)
// The count group renders first, so its "use default" row is index 0; tapping it persists null.
composeTestRule.onAllNodesWithText(string(R.string.retention_use_default))[0].performScrollTo().performClick()
assertEquals(null, reportedCount)
}
@Test
fun retentionSection_selectingACountOption_reportsItsValue() {
var reportedCount: Int? = null
setContent {
RetentionSection(
count = 0,
months = 0,
includeUseDefault = false,
onCountChange = { reportedCount = it },
onMonthsChange = {},
)
}
composeTestRule.onNodeWithText(string(R.string.retention_count_500)).performScrollTo().performClick()
assertEquals(500, reportedCount)
}
@Test
fun retentionSection_selectingAnAgeOption_reportsItsMonths() {
var reportedMonths: Int? = null
setContent {
RetentionSection(
count = 0,
months = 0,
includeUseDefault = false,
onCountChange = {},
onMonthsChange = { reportedMonths = it },
)
}
composeTestRule.onNodeWithText(string(R.string.retention_age_3m)).performScrollTo().performClick()
assertEquals(3, reportedMonths)
}
}
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
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.contacts.ContactPermissionState
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.FetchPolicy
import org.libremail.domain.model.Account
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 Compose tests for [SettingsScreen] + its stateless [ContactAutocompleteRow] (umbrella
* #373, batch #380). Drives the global settings screen on the JVM under [RobolectricTestRunner] via the
* v2 `createComposeRule()` — no emulator — with a MockK [SettingsViewModel] (its own logic is covered by
* `SettingsViewModelTest`) so `SettingsScreen.kt` counts toward JaCoCo's JVM-testable surface: the
* empty-vs-populated accounts branch, the fetch-policy radios, the appearance/notification/backup
* switches, the collapsible Advanced section, the battery subtitle branch, the retention wiring, and the
* contacts row's rationale-vs-blocked dialog branch. The instrumented `SettingsScreenTest` and
* `ContactAutocompleteRowTest` stay 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 SettingsScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
// collectAsStateWithLifecycle / LifecycleEventEffect need a LocalLifecycleOwner; the v2 rule hosts no
// Activity, so provide a RESUMED one by hand (createUnsafe skips the main-thread assertion on the JVM).
private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
private fun mockViewModel(
settings: AppSettings = AppSettings(),
accounts: List<Account> = emptyList(),
advancedExpanded: Boolean = false,
appLockMessage: Int? = null,
batteryUnrestricted: Boolean = false,
contactsRequested: Boolean = false,
hasContactsPermission: Boolean = false,
): SettingsViewModel {
val viewModel = mockk<SettingsViewModel>(relaxed = true)
every { viewModel.settings } returns MutableStateFlow(settings)
every { viewModel.accounts } returns MutableStateFlow(accounts)
every { viewModel.advancedExpanded } returns MutableStateFlow(advancedExpanded)
every { viewModel.appLockMessage } returns MutableStateFlow(appLockMessage)
every { viewModel.batteryUnrestricted } returns MutableStateFlow(batteryUnrestricted)
every { viewModel.contactsPermissionRequested } returns MutableStateFlow(contactsRequested)
every { viewModel.hasContactsPermission() } returns hasContactsPermission
return viewModel
}
private fun setContent(
viewModel: SettingsViewModel,
onAddAccount: () -> Unit = {},
onOpenAccount: (String) -> Unit = {},
onSelectTab: (org.libremail.ui.TopDest) -> Unit = {},
onReportProblem: () -> Unit = {},
) {
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SettingsScreen(
onAddAccount = onAddAccount,
onOpenAccount = onOpenAccount,
onSelectTab = onSelectTab,
onReportProblem = onReportProblem,
viewModel = viewModel,
)
}
}
}
}
private fun setRow(content: @Composable () -> Unit) {
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) { content() }
}
}
}
@Test
fun emptyAccounts_showsTitleAndNoAccountsMessage() {
setContent(mockViewModel(accounts = emptyList()))
// "Settings" also labels the bottom-nav tab, so match the (app-bar) title among all its nodes.
composeTestRule.onAllNodesWithText(string(R.string.title_settings)).onFirst().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_accounts)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_no_accounts)).assertIsDisplayed()
}
@Test
fun withAccounts_rendersAccountRow_andNoEmptyMessage() {
setContent(mockViewModel(accounts = listOf(Account.outlook("me@example.com"))))
composeTestRule.onNodeWithText("me@example.com").assertIsDisplayed()
composeTestRule.onAllNodesWithText(string(R.string.settings_no_accounts)).assertCountEquals(0)
}
@Test
fun tappingAddAccountRow_invokesCallback() {
var added = false
setContent(mockViewModel(), onAddAccount = { added = true })
composeTestRule.onNodeWithText(string(R.string.settings_add_account)).performScrollTo().performClick()
assertTrue(added)
}
@Test
fun selectingOnDemandFetchPolicy_callsViewModel() {
val viewModel = mockViewModel(settings = AppSettings(fetchPolicy = FetchPolicy.ALWAYS))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.fetch_on_demand)).performScrollTo().performClick()
verify { viewModel.setFetchPolicy(FetchPolicy.ON_DEMAND) }
}
@Test
fun togglingDynamicColor_callsViewModel() {
val viewModel = mockViewModel(settings = AppSettings(dynamicColor = false))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_dynamic_color)).performScrollTo().performClick()
verify { viewModel.setDynamicColor(true) }
}
@Test
fun togglingNewMailNotifications_callsViewModel() {
val viewModel = mockViewModel(settings = AppSettings(newMailNotifications = true))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_new_mail)).performScrollTo().performClick()
verify { viewModel.setNewMailNotifications(false) }
}
@Test
fun batteryRow_showsOptimizedSubtitle_whenRestricted() {
setContent(mockViewModel(batteryUnrestricted = false))
composeTestRule.onNodeWithText(string(R.string.settings_adv_battery_optimized))
.performScrollTo().assertIsDisplayed()
}
@Test
fun batteryRow_showsUnrestrictedSubtitle_whenUnrestricted() {
setContent(mockViewModel(batteryUnrestricted = true))
composeTestRule.onNodeWithText(string(R.string.settings_adv_battery_unrestricted))
.performScrollTo().assertIsDisplayed()
}
@Test
fun selectingGlobalRetentionCount_callsViewModel() {
val viewModel = mockViewModel(settings = AppSettings(retentionCount = 0))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.retention_count_500)).performScrollTo().performClick()
// The global screen maps a chosen count straight through (null coalesced to 0, but 500 here).
verify { viewModel.setRetentionCount(500) }
}
@Test
fun tappingReportProblemRow_invokesCallback() {
var reported = false
setContent(mockViewModel(), onReportProblem = { reported = true })
composeTestRule.onNodeWithText(string(R.string.settings_report_problem)).performScrollTo().performClick()
assertTrue(reported)
}
@Test
fun tappingAdvancedHeader_callsToggleAdvanced() {
val viewModel = mockViewModel(advancedExpanded = false)
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performScrollTo().performClick()
verify { viewModel.toggleAdvanced() }
}
@Test
fun advancedExpanded_showsAdvancedSwitches_andTogglingOneCallsViewModel() {
val viewModel = mockViewModel(
advancedExpanded = true,
settings = AppSettings(pushIdle = true, allowStartTls = false),
)
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.settings_adv_app_lock)).performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_adv_encrypt_cache))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_adv_starttls)).performScrollTo().performClick()
verify { viewModel.setAllowStartTls(true) }
}
@Test
fun contactsRow_deniedState_showsOffSubtitle_andTapOpensRationaleDialog() {
setContent(mockViewModel(contactsRequested = false, hasContactsPermission = false))
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).performScrollTo().performClick()
// DENIED (never asked, re-requestable) opens the rationale dialog before the system prompt. Assert
// on the unique rationale body — the dialog *title* reuses the row's own "Recipient autocomplete".
composeTestRule.onNodeWithText(string(R.string.settings_contacts_rationale)).assertIsDisplayed()
// Cancelling dismisses the dialog.
composeTestRule.onNodeWithText(string(R.string.cancel)).performClick()
composeTestRule.onAllNodesWithText(string(R.string.settings_contacts_rationale)).assertCountEquals(0)
}
@Test
fun contactsRow_blockedState_showsBlockedSubtitle_andTapOpensBlockedDialog() {
// granted = false, no activity (so no rationale), already requested → permanently blocked.
setContent(mockViewModel(contactsRequested = true, hasContactsPermission = false))
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).performScrollTo().performClick()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_blocked_body)).assertIsDisplayed()
}
// --- ContactAutocompleteRow (stateless, lives in SettingsScreen.kt) — mirrors ContactAutocompleteRowTest ---
@Test
fun contactAutocompleteRow_granted_showsOnSubtitle_andIsClickable() {
var clicked = false
setRow { ContactAutocompleteRow(state = ContactPermissionState.GRANTED, onClick = { clicked = true }) }
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).performClick()
assertTrue(clicked)
}
@Test
fun contactAutocompleteRow_denied_showsOffSubtitle() {
setRow { ContactAutocompleteRow(state = ContactPermissionState.DENIED, onClick = {}) }
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off)).assertIsDisplayed()
}
@Test
fun contactAutocompleteRow_blocked_showsBlockedSubtitle() {
setRow { ContactAutocompleteRow(state = ContactPermissionState.BLOCKED, onClick = {}) }
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked)).assertIsDisplayed()
}
}
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.isNotEnabled
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
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 Compose tests for [SignatureEditScreen] (umbrella #373, batch #380). Drives the
* signature editor on the JVM under [RobolectricTestRunner] via the v2 `createComposeRule()` — no
* emulator — with a MockK [SignatureEditViewModel] (its own logic is covered by
* `SignatureEditViewModelTest`) so `SignatureEditScreen.kt` counts toward JaCoCo's JVM-testable surface:
* the new-vs-edit title branch, the name field, the loaded/not-loaded body-field branch, the name-change
* callback, and the save button (including its saving-disabled state). The instrumented
* `SignatureEditScreenTest` 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 SignatureEditScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
private fun mockViewModel(isNew: Boolean, state: SignatureEditUiState): SignatureEditViewModel {
val viewModel = mockk<SignatureEditViewModel>(relaxed = true)
every { viewModel.isNew } returns isNew
every { viewModel.state } returns MutableStateFlow(state)
return viewModel
}
private fun setContent(viewModel: SignatureEditViewModel, onBack: () -> Unit = {}) {
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SignatureEditScreen(onBack = onBack, viewModel = viewModel)
}
}
}
}
@Test
fun newSignature_showsNewTitle_andNameField() {
setContent(mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true)))
composeTestRule.onNodeWithText(string(R.string.signature_new_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.signature_name)).assertIsDisplayed()
}
@Test
fun existingSignature_showsEditTitle_prefillsName_andShowsBodyField() {
setContent(
mockViewModel(
isNew = false,
state = SignatureEditUiState(
name = "Personal",
body = "Regards",
bodyHtml = "<p>Regards</p>",
loaded = true,
),
),
)
composeTestRule.onNodeWithText(string(R.string.signature_edit_title)).assertIsDisplayed()
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
// The rich-text body field (loaded == true) renders with the "signature content" label.
composeTestRule.onNodeWithText(string(R.string.signature_content)).assertIsDisplayed()
}
@Test
fun notLoaded_hidesBodyField() {
setContent(mockViewModel(isNew = false, state = SignatureEditUiState(name = "Personal", loaded = false)))
// The body field is gated on state.loaded, so its label is absent until the signature loads.
composeTestRule.onAllNodesWithText(string(R.string.signature_content)).assertCountEquals(0)
}
@Test
fun typingIntoName_callsOnNameChange() {
val viewModel = mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.signature_name)).performTextInput("Work")
verify { viewModel.onNameChange(any()) }
}
@Test
fun tappingSave_callsViewModel() {
val viewModel = mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true, saving = false))
setContent(viewModel)
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
verify { viewModel.save(any()) }
}
@Test
fun whileSaving_saveButtonIsDisabled() {
setContent(mockViewModel(isNew = true, state = SignatureEditUiState(loaded = true, saving = true)))
composeTestRule.onNodeWithText(string(R.string.signature_save)).assert(isNotEnabled())
}
}
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.isNotSelected
import androidx.compose.ui.test.isSelectable
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
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.Signature
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 Compose tests for [SignaturesScreen] (umbrella #373, batch #380). Drives the
* signatures list on the JVM under [RobolectricTestRunner] via the v2 `createComposeRule()` — no
* emulator — with a MockK [SignaturesViewModel] (its own logic is covered by `SignaturesViewModelTest`)
* so `SignaturesScreen.kt` counts toward JaCoCo's JVM-testable surface: the empty state, name +
* default-badge rendering, the row → edit tap, the make-default radio, the delete action, and the add FAB.
* The instrumented `SignaturesScreenTest` 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 SignaturesScreenJvmTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context get() = RuntimeEnvironment.getApplication()
private fun string(resId: Int): String = context.getString(resId)
private val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
override val lifecycle: Lifecycle get() = registry
}
private fun signature(id: String, name: String, isDefault: Boolean) =
Signature(id = id, accountId = "imap:a", name = name, html = "<p>$name body</p>", isDefault = isDefault)
private fun mockViewModel(signatures: List<Signature>): SignaturesViewModel {
val viewModel = mockk<SignaturesViewModel>(relaxed = true)
every { viewModel.signatures } returns MutableStateFlow(signatures)
return viewModel
}
private fun setContent(viewModel: SignaturesViewModel, onEdit: (String) -> Unit = {}, onAdd: () -> Unit = {}) {
composeTestRule.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SignaturesScreen(onBack = {}, onEdit = onEdit, onAdd = onAdd, viewModel = viewModel)
}
}
}
}
@Test
fun noSignatures_showsEmptyState() {
setContent(mockViewModel(emptyList()))
composeTestRule.onNodeWithText(string(R.string.signatures_empty)).assertIsDisplayed()
}
@Test
fun signatures_renderNames_withExactlyOneDefaultBadge() {
setContent(
mockViewModel(
listOf(
signature("s1", "Work", isDefault = true),
signature("s2", "Personal", isDefault = false),
),
),
)
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
composeTestRule.onAllNodesWithText(string(R.string.signature_default_badge)).assertCountEquals(1)
}
@Test
fun tappingASignatureRow_invokesOnEditWithItsId() {
var editedId: String? = null
setContent(
mockViewModel(
listOf(
signature("s1", "Work", isDefault = true),
signature("s2", "Personal", isDefault = false),
),
),
onEdit = { editedId = it },
)
composeTestRule.onNodeWithText("Personal").performClick()
assertEquals("s2", editedId)
}
@Test
fun tappingRadioOnNonDefault_callsSetDefault() {
val viewModel = mockViewModel(
listOf(
signature("s1", "Work", isDefault = true),
signature("s2", "Personal", isDefault = false),
),
)
setContent(viewModel)
// "Work" is the default (its radio is selected); the only unselected radio is "Personal"'s.
composeTestRule.onNode(isSelectable() and isNotSelected()).performClick()
verify { viewModel.setDefault("s2") }
}
@Test
fun tappingDelete_callsDeleteForThatRow() {
val viewModel = mockViewModel(
listOf(
signature("s1", "Work", isDefault = true),
signature("s2", "Personal", isDefault = false),
),
)
setContent(viewModel)
composeTestRule.onAllNodesWithContentDescription(string(R.string.signature_delete))[0].performClick()
verify { viewModel.delete("s1") }
}
@Test
fun tappingAddFab_invokesOnAdd() {
var added = false
setContent(mockViewModel(emptyList()), onAdd = { added = true })
composeTestRule.onNodeWithContentDescription(string(R.string.signatures_add)).performClick()
assertTrue(added)
}
}