Merge branch 'main' into test-382-robolectric-compose-editor

This commit is contained in:
Jason Ross
2026-07-07 00:23:13 -05:00
committed by GitHub
7 changed files with 1369 additions and 6 deletions
+8 -6
View File
@@ -327,12 +327,14 @@ val jacocoNonJvmTestableSurface = listOf(
"**/MailboxScreen*",
// 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,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)
}
}