diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 54b003b..9c786ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/test/kotlin/org/libremail/ui/reader/ReaderScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/reader/ReaderScreenJvmTest.kt new file mode 100644 index 0000000..51ffd41 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reader/ReaderScreenJvmTest.kt @@ -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-` (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 = Result.success(plainMessage), + attachments: List = emptyList(), + downloadedParts: Set = emptySet(), + ): MailRepository { + val repo = mockk(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() + 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(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 + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt new file mode 100644 index 0000000..7319046 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsScreenJvmTest.kt @@ -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(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) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt new file mode 100644 index 0000000..d40d66a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SettingsComponentsJvmTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt new file mode 100644 index 0000000..3901ba2 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SettingsScreenJvmTest.kt @@ -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 = emptyList(), + advancedExpanded: Boolean = false, + appLockMessage: Int? = null, + batteryUnrestricted: Boolean = false, + contactsRequested: Boolean = false, + hasContactsPermission: Boolean = false, + ): SettingsViewModel { + val viewModel = mockk(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() + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt new file mode 100644 index 0000000..6fccdf0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditScreenJvmTest.kt @@ -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(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 = "

Regards

", + 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()) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt new file mode 100644 index 0000000..00701d1 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SignaturesScreenJvmTest.kt @@ -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 = "

$name body

", isDefault = isDefault) + + private fun mockViewModel(signatures: List): SignaturesViewModel { + val viewModel = mockk(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) + } +}