test(compose): Robolectric JVM tests — mail list screens drafts/outbox/reports (batch 4/9) #397

Merged
JMR-dev merged 3 commits from test-379-robolectric-mail-lists into main 2026-07-07 04:15:26 +00:00
4 changed files with 432 additions and 3 deletions
+2 -3
View File
@@ -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*",
@@ -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<Draft>, onOpenDraft: (String) -> Unit = {}): MailRepository {
val draftsFlow = MutableStateFlow(drafts)
val repo = mockk<MailRepository>(relaxed = true)
every { repo.observeDrafts() } returns draftsFlow
coEvery { repo.deleteDraft(any()) } answers {
val id = firstArg<String>()
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
}
}
@@ -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<OutboxMessage>): MailRepository {
val outboxFlow = MutableStateFlow(messages)
val repo = mockk<MailRepository>(relaxed = true)
every { repo.observeOutbox() } returns outboxFlow
coEvery { repo.cancelOutboxMessage(any()) } answers {
val id = firstArg<String>()
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
}
}
@@ -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<DebugReport>,
manualReport: DebugReport? = null,
onOpenReport: (String) -> Unit = {},
) {
val store = mockk<ReportStore>(relaxed = true)
every { store.reports } returns MutableStateFlow(reports)
val collector = mockk<DiagnosticsCollector>(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
}
}