test(coverage): lane 5 — Compose screens E2E coverage
Add instrumented Compose UI/E2E tests for five previously-untested screens, raising the ui/** view-layer coverage (issue #250): - OutboxScreen: empty state, queued/failed rows, retry + cancel actions - DraftsScreen: empty state, subject/recipient/body render + blank fallbacks, open + delete - ProblemReportsScreen: empty state, crash/manual rows, open, create-and-review flow (real ReportStore + DiagnosticsCollector) - AccountPickerScreen: all setup choices listed, app-password + manual routing - SignaturesScreen: empty state, default badge, make-default (DB round-trip), delete (real in-memory Room + SignatureRepository) Extend the shared FakeMailRepository to back observeOutbox()/observeDrafts() with mutable state and record cancel/retry so the screens' actions are exercised end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,8 @@ class FakeMailRepository(
|
||||
private val attachments: List<Attachment> = emptyList(),
|
||||
private val downloadedParts: Set<Int> = emptySet(),
|
||||
private val unreadCounts: List<UnreadCount> = emptyList(),
|
||||
drafts: List<Draft> = emptyList(),
|
||||
outbox: List<OutboxMessage> = emptyList(),
|
||||
) : MailRepository {
|
||||
|
||||
val sentMessages = mutableListOf<OutgoingMessage>()
|
||||
@@ -87,6 +89,14 @@ class FakeMailRepository(
|
||||
val expungedIds = mutableListOf<List<String>>()
|
||||
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
|
||||
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
|
||||
val canceledOutboxIds = mutableListOf<String>()
|
||||
var retryOutboxCount = 0
|
||||
private set
|
||||
|
||||
// Backed by mutable state so a delete/cancel is reflected in the observed list, letting UI tests
|
||||
// assert the row actually disappears (mirroring the real DB-backed repository's reactivity).
|
||||
private val draftsFlow = MutableStateFlow(drafts)
|
||||
private val outboxFlow = MutableStateFlow(outbox)
|
||||
|
||||
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> =
|
||||
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
|
||||
@@ -179,9 +189,9 @@ class FakeMailRepository(
|
||||
return sendResult
|
||||
}
|
||||
|
||||
override fun observeDrafts(): Flow<List<Draft>> = flowOf(emptyList())
|
||||
override fun observeDrafts(): Flow<List<Draft>> = draftsFlow
|
||||
|
||||
override suspend fun getDraft(id: String): Draft? = null
|
||||
override suspend fun getDraft(id: String): Draft? = draftsFlow.value.firstOrNull { it.id == id }
|
||||
|
||||
override suspend fun saveDraft(draft: Draft) {
|
||||
savedDrafts += draft
|
||||
@@ -189,13 +199,19 @@ class FakeMailRepository(
|
||||
|
||||
override suspend fun deleteDraft(id: String) {
|
||||
deletedDraftIds += id
|
||||
draftsFlow.value = draftsFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override fun observeOutbox(): Flow<List<OutboxMessage>> = flowOf(emptyList())
|
||||
override fun observeOutbox(): Flow<List<OutboxMessage>> = outboxFlow
|
||||
|
||||
override suspend fun cancelOutboxMessage(id: String) {}
|
||||
override suspend fun cancelOutboxMessage(id: String) {
|
||||
canceledOutboxIds += id
|
||||
outboxFlow.value = outboxFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override suspend fun retryOutbox() {}
|
||||
override suspend fun retryOutbox() {
|
||||
retryOutboxCount++
|
||||
}
|
||||
|
||||
override suspend fun searchServer(query: String, accountId: String?, folder: String) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.accountsetup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for the account-vendor picker used by onboarding and "Add account". Drives the
|
||||
* real [AccountPickerScreen] + [AccountSetupViewModel]: every setup choice is listed, tapping an
|
||||
* app-password vendor routes to [onPickProvider] with that [MailProvider], and tapping "Other"
|
||||
* routes to [onManualSetup]. The Outlook row is deliberately not tapped (it launches a browser).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountPickerScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(onPickProvider: (MailProvider) -> Unit = {}, onManualSetup: () -> Unit = {}) {
|
||||
val viewModel = AccountSetupViewModel(OutlookAuthManager(context), FakeAccountRepository())
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AccountPickerScreen(
|
||||
onBack = {},
|
||||
onAccountAdded = {},
|
||||
onPickProvider = onPickProvider,
|
||||
onManualSetup = onManualSetup,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allSetupChoices_areListed() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performScrollTo().assertIsDisplayed()
|
||||
MailProvider.entries.forEach { provider ->
|
||||
composeTestRule.onNodeWithText(provider.displayName).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAppPasswordProvider_invokesOnPickProvider() {
|
||||
var picked: MailProvider? = null
|
||||
setContent(onPickProvider = { picked = it })
|
||||
|
||||
composeTestRule.onNodeWithText(MailProvider.GMAIL.displayName).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { picked == MailProvider.GMAIL }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingOther_invokesOnManualSetup() {
|
||||
var manualRequested = false
|
||||
setContent(onManualSetup = { manualRequested = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { manualRequested }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.drafts
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.domain.model.Draft
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [DraftsScreen] + [DraftsViewModel] over an in-memory [FakeMailRepository]:
|
||||
* the empty state, the subject/recipient/body rendering (with the blank-field fallbacks), opening a
|
||||
* draft, and deleting one (which removes the row and records the deletion in the repository).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DraftsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.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,
|
||||
)
|
||||
|
||||
private fun setContent(repo: FakeMailRepository, onOpenDraft: (String) -> Unit = {}) {
|
||||
val viewModel = DraftsViewModel(repo)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
DraftsScreen(onBack = {}, onOpenDraft = onOpenDraft, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDrafts_showsEmptyState() {
|
||||
setContent(FakeMailRepository())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.drafts_empty)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedDraft_showsSubjectRecipientAndBody() {
|
||||
setContent(
|
||||
FakeMailRepository(
|
||||
drafts = 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(FakeMailRepository(drafts = 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(
|
||||
FakeMailRepository(drafts = listOf(draft("d1", "alice@example.org", "Lunch plans", "body"))),
|
||||
onOpenDraft = { opened = it },
|
||||
)
|
||||
waitForText("Lunch plans")
|
||||
|
||||
composeTestRule.onNodeWithText("Lunch plans").performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { opened == "d1" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDelete_removesRow_andRecordsDeletion() {
|
||||
val repo = FakeMailRepository(
|
||||
drafts = listOf(
|
||||
draft("d1", "alice@example.org", "Lunch plans", "body"),
|
||||
draft("d2", "bob@example.org", "Second draft", "body"),
|
||||
),
|
||||
)
|
||||
setContent(repo)
|
||||
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(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Lunch plans").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
assertEquals(listOf("d1"), repo.deletedDraftIds)
|
||||
composeTestRule.onNodeWithText("Second draft").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.outbox
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.domain.model.OutboxMessage
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [OutboxScreen] + [OutboxViewModel] over an in-memory [FakeMailRepository]:
|
||||
* the empty state, queued-vs-failed row rendering, and the cancel/retry actions round-tripping
|
||||
* through the repository (cancel removes the row; retry calls back into the repo).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OutboxScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.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")
|
||||
|
||||
private fun setContent(repo: FakeMailRepository) {
|
||||
val viewModel = OutboxViewModel(repo)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
OutboxScreen(onBack = {}, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyOutbox_showsEmptyState_andNoRetryAction() {
|
||||
setContent(FakeMailRepository())
|
||||
|
||||
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(
|
||||
FakeMailRepository(
|
||||
outbox = 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() {
|
||||
val repo = FakeMailRepository(outbox = listOf(failed("f", "Failed mail", "bob@example.org")))
|
||||
setContent(repo)
|
||||
waitForText("Failed mail")
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.retryOutboxCount == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCancel_removesRow_andRecordsCancellation() {
|
||||
val repo = FakeMailRepository(
|
||||
outbox = listOf(
|
||||
queued("q", "Queued mail", "alice@example.org"),
|
||||
queued("q2", "Second mail", "carol@example.org"),
|
||||
),
|
||||
)
|
||||
setContent(repo)
|
||||
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(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Queued mail").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
assertEquals(listOf("q"), repo.canceledOutboxIds)
|
||||
composeTestRule.onNodeWithText("Second mail").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.reporting.AppVersionProvider
|
||||
import org.libremail.reporting.DebugReport
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [ProblemReportsScreen] + [ProblemReportsViewModel] backed by a real
|
||||
* file-backed [ReportStore] (temp dir) and a real [DiagnosticsCollector]: 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.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProblemReportsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val storeDir = File(context.cacheDir, "problem-reports-test")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
storeDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
storeDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.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(),
|
||||
)
|
||||
|
||||
private fun setContent(reports: List<DebugReport>, onOpenReport: (String) -> Unit = {}) {
|
||||
val store = ReportStore(storeDir)
|
||||
reports.forEach(store::save)
|
||||
val collector = DiagnosticsCollector(
|
||||
AppVersionProvider(context),
|
||||
SettingsRepository(context),
|
||||
RingLogBuffer(),
|
||||
)
|
||||
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(5_000) {
|
||||
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(5_000) { opened == "r-crash" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCreate_savesManualReport_andOpensItForReview() {
|
||||
var opened: String? = null
|
||||
setContent(emptyList(), 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(5_000) { opened != null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
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.isSelected
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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 androidx.lifecycle.SavedStateHandle
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [SignaturesScreen] + [SignaturesViewModel] over a real in-memory Room DB
|
||||
* and [SignatureRepository]: the empty state, name + default-badge rendering, making another
|
||||
* signature the default (round-tripping through the DB via the radio), and deleting a signature
|
||||
* (its row disappears as the repository re-emits the account's list).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignaturesScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val accountId = "imap:a"
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var repository: SignatureRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
repository = SignatureRepository(db.signatureDao())
|
||||
// Signatures foreign-key to an account row, so the parent account must exist first.
|
||||
runBlocking {
|
||||
db.accountDao().upsert(
|
||||
AccountEntity(
|
||||
id = accountId,
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
/** Seeds signatures (the first becomes the account's default) then renders the screen. */
|
||||
private fun setContent(vararg names: String) {
|
||||
runBlocking { names.forEach { repository.create(accountId, it, "<p>$it body</p>") } }
|
||||
val viewModel = SignaturesViewModel(
|
||||
SavedStateHandle(mapOf(Routes.SIGNATURES_ARG_ACCOUNT to accountId)),
|
||||
repository,
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
SignaturesScreen(onBack = {}, onEdit = {}, onAdd = {}, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSignatures_showsEmptyState() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.signatures_empty)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatures_renderNames_withExactlyOneDefaultBadge() {
|
||||
setContent("Work", "Personal")
|
||||
waitForText("Work")
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
|
||||
// The first signature created is the account's sole default, so exactly one badge shows.
|
||||
composeTestRule.onAllNodesWithText(string(R.string.signature_default_badge)).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingRadioOnNonDefault_makesItTheDefault() {
|
||||
setContent("Work", "Personal")
|
||||
waitForText("Work")
|
||||
|
||||
// "Work" (created first) is the default, so its radio is selected and "Personal"'s is not.
|
||||
composeTestRule.onAllNodes(isSelectable() and isSelected()).assertCountEquals(1)
|
||||
composeTestRule.onNode(isSelectable() and isNotSelected()).performClick()
|
||||
|
||||
// The tap round-trips through the repository/DB: "Personal" is now the account's default.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { repository.getDefault(accountId)?.name } == "Personal"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDelete_removesTheRow() {
|
||||
setContent("Work", "Personal")
|
||||
waitForText("Work")
|
||||
|
||||
// Delete the first (default) signature; the repository re-emits, dropping its row.
|
||||
composeTestRule.onAllNodesWithContentDescription(string(R.string.signature_delete))[0].performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Work").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user