diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a6d3d3..7ced059 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -318,16 +318,15 @@ val jacocoNonJvmTestableSurface = listOf( "**/ComposeScreen*", // ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to // Robolectric JVM Compose tests (#376) — now JVM-covered. - "**/DraftsScreen*", + // DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests + // (#379) — now JVM-covered. // LockScreen converted to a Robolectric JVM Compose test (#377) — now JVM-covered. "**/AppLockGateHost*", "**/FolderDrawer*", "**/MailboxScreen*", // AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps // (#377) converted to Robolectric JVM Compose tests — now JVM-covered. - "**/OutboxScreen*", "**/ReaderScreen*", - "**/ProblemReportsScreen*", "**/AccountSettingsScreen*", "**/SettingsScreen*", "**/SettingsComponents*", diff --git a/app/src/test/kotlin/org/libremail/ui/drafts/DraftsScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/drafts/DraftsScreenJvmTest.kt new file mode 100644 index 0000000..29c73e2 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/drafts/DraftsScreenJvmTest.kt @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +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.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +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.domain.model.Draft +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented [DraftsScreenTest] (batch 4/9 of umbrella #373): drives + * [DraftsScreen] + the real [DraftsViewModel] over a mocked [MailRepository] on the JVM via the v2 + * `createComposeRule()` — no emulator — so [DraftsScreen]'s render + interaction code counts toward + * JaCoCo's JVM-testable surface. Covers the empty state, the subject/recipient/body rendering with + * the blank-field fallbacks, opening a draft, and deleting one (row disappears + delegates to the + * repository). The instrumented [DraftsScreenTest] 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 DraftsScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int) = context.getString(resId) + + private fun draft(id: String, to: String, subject: String, body: String) = Draft( + id = id, + accountId = "imap:a", + to = to, + cc = "", + subject = subject, + body = body, + updatedAt = 1_000L, + ) + + /** + * Builds a relaxed [MailRepository] whose observed drafts are backed by a [MutableStateFlow], so a + * `deleteDraft` mutates the observed list and the row actually disappears (mirroring the DB-backed + * repository's reactivity), and constructs the real [DraftsViewModel] over it. + */ + private fun setContent(drafts: List, onOpenDraft: (String) -> Unit = {}): MailRepository { + val draftsFlow = MutableStateFlow(drafts) + val repo = mockk(relaxed = true) + every { repo.observeDrafts() } returns draftsFlow + coEvery { repo.deleteDraft(any()) } answers { + val id = firstArg() + draftsFlow.value = draftsFlow.value.filterNot { it.id == id } + } + val viewModel = DraftsViewModel(repo) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + DraftsScreen(onBack = {}, onOpenDraft = onOpenDraft, viewModel = viewModel) + } + } + return repo + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun emptyDrafts_showsEmptyState() { + setContent(emptyList()) + + composeTestRule.onNodeWithText(string(R.string.drafts_empty)).assertIsDisplayed() + } + + @Test + fun populatedDraft_showsSubjectRecipientAndBody() { + setContent(listOf(draft("d1", "alice@example.org", "Lunch plans", "See you at noon"))) + waitForText("Lunch plans") + + composeTestRule.onNodeWithText("Lunch plans").assertIsDisplayed() + composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed() + composeTestRule.onNodeWithText("See you at noon").assertIsDisplayed() + } + + @Test + fun blankDraft_usesNoSubjectAndNoRecipientFallbacks() { + setContent(listOf(draft("d1", to = "", subject = "", body = ""))) + waitForText(string(R.string.draft_no_subject)) + + composeTestRule.onNodeWithText(string(R.string.draft_no_subject)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.draft_no_recipient)).assertIsDisplayed() + } + + @Test + fun tappingRow_invokesOnOpenDraft() { + var opened: String? = null + setContent( + listOf(draft("d1", "alice@example.org", "Lunch plans", "body")), + onOpenDraft = { opened = it }, + ) + waitForText("Lunch plans") + + composeTestRule.onNodeWithText("Lunch plans").performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { opened == "d1" } + } + + @Test + fun tappingDelete_removesRow_andRecordsDeletion() { + val repo = setContent( + listOf( + draft("d1", "alice@example.org", "Lunch plans", "body"), + draft("d2", "bob@example.org", "Second draft", "body"), + ), + ) + waitForText("Lunch plans") + + // Delete the first draft: the fake removes it from the observed list, so its row disappears. + composeTestRule.onAllNodesWithContentDescription(string(R.string.draft_delete))[0].performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText("Lunch plans").fetchSemanticsNodes().isEmpty() + } + coVerify(exactly = 1) { repo.deleteDraft("d1") } + composeTestRule.onNodeWithText("Second draft").assertIsDisplayed() + } + + private companion object { + const val TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/outbox/OutboxScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/outbox/OutboxScreenJvmTest.kt new file mode 100644 index 0000000..f05d21d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/outbox/OutboxScreenJvmTest.kt @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +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.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +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.domain.model.OutboxMessage +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented [OutboxScreenTest] (batch 4/9 of umbrella #373): drives + * [OutboxScreen] + the real [OutboxViewModel] over a mocked [MailRepository] on the JVM via the v2 + * `createComposeRule()` — no emulator — so [OutboxScreen]'s render + interaction code counts toward + * JaCoCo's JVM-testable surface. Covers the empty state (no retry action), the queued-vs-failed row + * rendering, retry, and cancel (row disappears + delegates to the repository). The instrumented + * [OutboxScreenTest] 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 OutboxScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int) = context.getString(resId) + + private fun queued(id: String, subject: String, to: String) = + OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 1_000L, lastError = null) + + private fun failed(id: String, subject: String, to: String) = + OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 2_000L, lastError = "SMTP 550") + + /** + * Builds a relaxed [MailRepository] whose observed outbox is backed by a [MutableStateFlow], so a + * `cancelOutboxMessage` mutates the observed list and the row disappears (mirroring the DB-backed + * repository's reactivity), and constructs the real [OutboxViewModel] over it. + */ + private fun setContent(messages: List): MailRepository { + val outboxFlow = MutableStateFlow(messages) + val repo = mockk(relaxed = true) + every { repo.observeOutbox() } returns outboxFlow + coEvery { repo.cancelOutboxMessage(any()) } answers { + val id = firstArg() + outboxFlow.value = outboxFlow.value.filterNot { it.id == id } + } + val viewModel = OutboxViewModel(repo) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + OutboxScreen(onBack = {}, viewModel = viewModel) + } + } + return repo + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun emptyOutbox_showsEmptyState_andNoRetryAction() { + setContent(emptyList()) + + composeTestRule.onNodeWithText(string(R.string.outbox_empty)).assertIsDisplayed() + // Retry only appears when there is something to retry. + composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertDoesNotExist() + } + + @Test + fun populatedOutbox_showsQueuedAndFailedRows_andRetryAction() { + setContent( + listOf( + queued("q", "Queued mail", "alice@example.org"), + failed("f", "Failed mail", "bob@example.org"), + ), + ) + waitForText("Queued mail") + + composeTestRule.onNodeWithText("Queued mail").assertIsDisplayed() + composeTestRule.onNodeWithText("Failed mail").assertIsDisplayed() + // The queued row shows the "queued" status; the failed row shows the "failed" status. + composeTestRule.onNodeWithText(string(R.string.outbox_status_queued)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outbox_status_failed)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertIsDisplayed() + } + + @Test + fun tappingRetry_callsRepositoryRetry() { + var retryCount = 0 + val repo = setContent(listOf(failed("f", "Failed mail", "bob@example.org"))) + coEvery { repo.retryOutbox() } answers { retryCount++ } + waitForText("Failed mail") + + composeTestRule.onNodeWithText(string(R.string.outbox_retry)).performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { retryCount == 1 } + } + + @Test + fun tappingCancel_removesRow_andRecordsCancellation() { + val repo = setContent( + listOf( + queued("q", "Queued mail", "alice@example.org"), + queued("q2", "Second mail", "carol@example.org"), + ), + ) + waitForText("Queued mail") + + // Cancel the first row: the fake removes it from the observed outbox, so the row disappears. + composeTestRule.onAllNodesWithContentDescription(string(R.string.outbox_cancel))[0].performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText("Queued mail").fetchSemanticsNodes().isEmpty() + } + coVerify(exactly = 1) { repo.cancelOutboxMessage("q") } + composeTestRule.onNodeWithText("Second mail").assertIsDisplayed() + } + + private companion object { + const val TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ProblemReportsScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ProblemReportsScreenJvmTest.kt new file mode 100644 index 0000000..08647d6 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/ProblemReportsScreenJvmTest.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import android.content.Context +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.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +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.reporting.DebugReport +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM port of the instrumented [ProblemReportsScreenTest] (batch 4/9 of umbrella #373): + * drives [ProblemReportsScreen] + the real [ProblemReportsViewModel] over a mocked [ReportStore] and + * [DiagnosticsCollector] on the JVM via the v2 `createComposeRule()` — no emulator — so + * [ProblemReportsScreen]'s render + interaction code counts toward JaCoCo's JVM-testable surface. + * Covers the always-present create button and auto-delete notice, the empty state, the crash/manual + * row rendering, opening a report, and the create-report flow which saves a manual report and + * immediately opens it for review. The instrumented [ProblemReportsScreenTest] 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 ProblemReportsScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int) = context.getString(resId) + + private fun report(id: String, kind: ReportKind) = DebugReport( + id = id, + createdAtMillis = 1_000L, + kind = kind, + appVersionName = "1.0", + appVersionCode = 1L, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Test", + deviceModel = "Model", + stackTrace = if (kind == ReportKind.CRASH) "boom" else null, + settings = emptyMap(), + logs = emptyList(), + ) + + /** + * Builds a real [ProblemReportsViewModel] over a mocked [ReportStore] (its [ReportStore.reports] + * seeded with [reports]) and a mocked [DiagnosticsCollector] whose `collectManual` returns + * [manualReport] (drives the create-and-open flow), then renders [ProblemReportsScreen]. + */ + private fun setContent( + reports: List, + manualReport: DebugReport? = null, + onOpenReport: (String) -> Unit = {}, + ) { + val store = mockk(relaxed = true) + every { store.reports } returns MutableStateFlow(reports) + val collector = mockk(relaxed = true) + if (manualReport != null) coEvery { collector.collectManual() } returns manualReport + val viewModel = ProblemReportsViewModel(store, collector) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ProblemReportsScreen(onBack = {}, onOpenReport = onOpenReport, viewModel = viewModel) + } + } + } + + private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun noReports_showsEmptyState_withCreateButtonAndAutoDeleteNotice() { + setContent(emptyList()) + + composeTestRule.onNodeWithText(string(R.string.reports_empty)).assertIsDisplayed() + // The create control and the retention notice are always visible, even with no reports. + composeTestRule.onNodeWithText(string(R.string.reports_create)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.report_auto_delete_notice)).assertIsDisplayed() + } + + @Test + fun storedReports_showCrashAndManualKindLabels() { + setContent(listOf(report("r-crash", ReportKind.CRASH), report("r-manual", ReportKind.MANUAL))) + waitForText(string(R.string.report_kind_crash)) + + composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.report_kind_manual)).assertIsDisplayed() + } + + @Test + fun tappingReportRow_invokesOnOpenReport() { + var opened: String? = null + setContent(listOf(report("r-crash", ReportKind.CRASH)), onOpenReport = { opened = it }) + waitForText(string(R.string.report_kind_crash)) + + composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).performClick() + + composeTestRule.waitUntil(TIMEOUT_MS) { opened == "r-crash" } + } + + @Test + fun tappingCreate_savesManualReport_andOpensItForReview() { + var opened: String? = null + setContent( + emptyList(), + manualReport = report("r-new", ReportKind.MANUAL), + onOpenReport = { opened = it }, + ) + + composeTestRule.onNodeWithText(string(R.string.reports_create)).performClick() + + // Creating a manual report emits its id so the screen opens it straight into review. + composeTestRule.waitUntil(TIMEOUT_MS) { opened == "r-new" } + } + + private companion object { + const val TIMEOUT_MS = 5_000L + } +}