Update TOTP/Passkey management UX.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:37:43 -04:00
committed by Cody Henthorne
parent 141cd31070
commit 03256a7ceb
42 changed files with 933 additions and 2292 deletions
@@ -6,12 +6,15 @@
package org.signal.appsettings.account
import android.app.Application
import android.text.format.DateUtils
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -26,12 +29,30 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.signal.appsettings.R
import org.signal.appsettings.account.AccountSettingsState.Dialog
import org.signal.appsettings.account.AccountSettingsState.LoadState
import org.signal.core.ui.compose.Dialogs
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AccountSettingsScreenTest {
companion object {
/** Fixed so the "Added ..." subtitle a row renders is something the test can predict. */
private const val CREATED_AT = 1_700_000_000_000L
private val ADDED_TIME: String = DateUtils.getRelativeDateTimeString(
RuntimeEnvironment.getApplication(),
CREATED_AT,
DateUtils.DAY_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
0
).toString()
private val METHODS = listOf(
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = CREATED_AT),
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = CREATED_AT)
)
}
private val context: Application = RuntimeEnvironment.getApplication()
@get:Rule
@@ -299,12 +320,12 @@ class AccountSettingsScreenTest {
setContent(createState())
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertDoesNotExist()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).assertDoesNotExist()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).assertDoesNotExist()
}
@Test
fun givenASignalLogin_whenIClickTheSignalLoginCard_thenIExpectAccountAndRecoveryEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
setContent(createState(signalLogin = signalLogin()))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).performClick()
@@ -312,40 +333,106 @@ class AccountSettingsScreenTest {
}
@Test
fun givenASignalLogin_whenIClickTotpApp_thenIExpectTotpAppEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
fun givenASignalLogin_whenIPickAuthenticatorAppFromTheSetUpMenu_thenIExpectAddTotpAppEvent() {
setContent(createState(signalLogin = signalLogin()))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_AUTHENTICATOR_APP).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).performClick()
assertThat(events).contains(AccountSettingsEvent.TotpAppClicked)
assertThat(events).contains(AccountSettingsEvent.AddTotpAppClicked)
}
/** A count we couldn't fetch has to read the same as no count at all, rather than as "0 configured". */
/** Passkeys aren't supported yet, so the menu can't offer to set one up. */
@Test
fun givenAnUnknownTotpAppCount_whenScreenDisplayed_thenTheRowDoesNotClaimACount() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = null, passkeyCount = 0)))
fun givenTheSetUpMenu_whenItIsOpen_thenPasskeyIsNotOffered() {
setContent(createState(signalLogin = signalLogin()))
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__one_time_verification_codes)).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick()
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__passkey)).assertDoesNotExist()
}
@Test
fun givenConfiguredTotpApps_whenScreenDisplayed_thenTheRowShowsTheCount() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 0)))
fun givenTwoFactorMethods_whenScreenDisplayed_thenIExpectARowPerMethod() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
composeTestRule.onNodeWithText(context.resources.getQuantityString(R.plurals.AccountSettingsFragment__d_configured, 2, 2)).assertIsDisplayed()
for (method in METHODS) {
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(method.name))
composeTestRule.onNodeWithText(method.name).assertIsDisplayed()
}
}
/** Authenticator apps and passkeys share one list, so a row's subtitle is what says which kind it is. */
@Test
fun givenTwoFactorMethods_whenScreenDisplayed_thenEachRowSaysWhatKindItIs() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
for (kind in listOf(R.string.AccountSettingsFragment__authenticator_app, R.string.AccountSettingsFragment__passkey)) {
val label = context.getString(R.string.AccountSettingsFragment__s_added_s, context.getString(kind), ADDED_TIME)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(label))
composeTestRule.onNodeWithText(label).assertIsDisplayed()
}
}
@Test
fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
fun givenTwoFactorMethods_whenIClickRenameInTheMenu_thenIExpectRenameMethodEvent() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
scrollTo(AccountSettingsTestTags.ROW_PASSKEYS)
scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_RENAME).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PASSKEYS).performClick()
assertThat(events).contains(AccountSettingsEvent.RenameMethodClicked(METHODS[0]))
}
assertThat(events).contains(AccountSettingsEvent.PasskeysClicked)
@Test
fun givenTwoFactorMethods_whenIClickRemoveInTheMenu_thenIExpectRemoveMethodEvent() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(AccountSettingsEvent.RemoveMethodClicked(METHODS[0]))
}
@Test
fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveTotpAppConfirmedForThatApp() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS), dialog = Dialog.ConfirmRemoveTotpApp(METHODS[0].id)))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_CONFIRM_REMOVE_TOTP_APP).assertIsDisplayed()
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
assertThat(events).contains(AccountSettingsEvent.RemoveTotpAppConfirmed)
}
@Test
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
setContent(createState(signalLogin = signalLogin(), dialog = Dialog.MaxTotpAppsReached))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED).assertIsDisplayed()
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked)
assertThat(events).contains(AccountSettingsEvent.DialogDismissed)
}
@Test
fun whenTheTwoFactorListHasntArrived_thenIExpectASpinnerRatherThanAnEmptyList() {
setContent(createState(signalLogin = signalLogin(loadState = LoadState.LOADING)))
scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOADING)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertIsDisplayed()
}
/** An account we couldn't ask about is not an account with no second factors. */
@Test
fun givenTheTwoFactorListCouldntBeLoaded_whenScreenDisplayed_thenIExpectTheFailureMessage() {
setContent(createState(signalLogin = signalLogin(loadState = LoadState.NETWORK_FAILURE)))
scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertDoesNotExist()
}
@Test
@@ -374,6 +461,14 @@ class AccountSettingsScreenTest {
}
}
private fun signalLogin(
twoFactorMethods: List<TwoFactorMethod> = emptyList(),
loadState: LoadState = LoadState.LOADED,
maxTotpApps: Int = 2
): AccountSettingsState.SignalLogin {
return AccountSettingsState.SignalLogin(twoFactorMethods = twoFactorMethods, loadState = loadState, maxTotpApps = maxTotpApps)
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
@@ -1,111 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class PasskeysScreenTest {
companion object {
private val PASSKEYS = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<PasskeysEvent>()
@Test
fun givenNoPasskeys_whenIClickSetUpAPasskey_thenIExpectSetUpPasskeyEvent() {
setContent(PasskeysState())
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
}
@Test
fun givenPasskeys_whenIDisplayScreen_thenIExpectARowPerPasskey() {
setContent(PasskeysState(passkeys = PASSKEYS))
for (passkey in PASSKEYS) {
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER).performScrollToNode(hasText(passkey.name))
composeTestRule.onNodeWithText(passkey.name).assertIsDisplayed()
}
}
@Test
fun givenPasskeys_whenIClickAddANewPasskey_thenIExpectSetUpPasskeyEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
}
@Test
fun givenPasskeys_whenIClickRenameInAPasskeysMenu_thenIExpectRenameEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_RENAME).performClick()
assertThat(events).contains(PasskeysEvent.RenamePasskeyClicked(passkeyId = PASSKEYS[0].id))
}
@Test
fun givenPasskeys_whenIClickRemoveInAPasskeysMenu_thenIExpectRemoveEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(PasskeysEvent.RemovePasskeyClicked(passkeyId = PASSKEYS[0].id))
}
private fun setContent(state: PasskeysState) {
composeTestRule.setContent {
PasskeysScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}
@@ -1,104 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PasskeysViewModelTest {
companion object {
private val PASSKEYS = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
}
private val testDispatcher = UnconfinedTestDispatcher()
private val repository = object : PasskeysRepository {
override fun getPasskeys(): List<Passkey> = PASSKEYS
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the passkeys are available as soon as the screen opens`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
assertThat(viewModel.state.value.passkeys).isEqualTo(PASSKEYS)
}
@Test
fun `SetUpPasskeyClicked launches passkey creation`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.SetUpPasskeyClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.LaunchPasskeyCreation)
}
@Test
fun `LearnMoreClicked opens the learn more article`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.LearnMoreClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.OpenLearnMore)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.NavigateBack)
}
@Test
fun `rename and remove produce no actions yet`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.RenamePasskeyClicked(passkeyId = 1))
viewModel.onEvent(PasskeysEvent.RemovePasskeyClicked(passkeyId = 1))
assertThat(actions).isEmpty()
}
private fun TestScope.collectActions(actions: Flow<PasskeysAction>): List<PasskeysAction> {
val collected = mutableListOf<PasskeysAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -1,172 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
import org.signal.core.ui.compose.Dialogs
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class TotpAppListScreenTest {
companion object {
private val APPS = listOf(
TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()),
TotpApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis())
)
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<TotpAppListEvent>()
@Test
fun givenNoApps_whenIDisplayScreen_thenIExpectTheEmptyMessage() {
setContent(TotpAppListState(loadState = LoadState.LOADED))
scrollTo(TotpAppListTestTags.EMPTY_MESSAGE)
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertIsDisplayed()
}
@Test
fun givenApps_whenIDisplayScreen_thenIExpectARowPerApp() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
for (app in APPS) {
composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER).performScrollToNode(hasText(app.name))
composeTestRule.onNodeWithText(app.name).assertIsDisplayed()
}
}
@Test
fun whenIClickAddTotpApp_thenIExpectAddTotpAppClickedEvent() {
setContent(TotpAppListState(loadState = LoadState.LOADED))
composeTestRule.onNodeWithTag(TotpAppListTestTags.BUTTON_ADD)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(TotpAppListEvent.AddTotpAppClicked)
}
@Test
fun givenApps_whenIClickRenameInTheMenu_thenIExpectRenameAppClickedEvent() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
scrollTo(TotpAppListTestTags.ROW_APP)
composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick()
composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_RENAME).performClick()
assertThat(events).contains(TotpAppListEvent.RenameAppClicked(appId = APPS[0].id))
}
@Test
fun givenApps_whenIClickRemoveInTheMenu_thenIExpectRemoveAppClickedEvent() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
scrollTo(TotpAppListTestTags.ROW_APP)
composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick()
composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(TotpAppListEvent.RemoveAppClicked(appId = APPS[0].id))
}
@Test
fun givenTheConfirmRemoveDialog_whenIDisplayScreen_thenIExpectTheDialog() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id)))
composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_CONFIRM_REMOVE).assertIsDisplayed()
}
/** The event has to carry the id, since the dialog holding it is dismissed before the confirmation is reported. */
@Test
fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveAppConfirmedForThatApp() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[1].id)))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
assertThat(events).contains(TotpAppListEvent.RemoveAppConfirmed(APPS[1].id))
}
@Test
fun givenTheConfirmRemoveDialog_whenICancel_thenIExpectDialogDismissedEvent() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id)))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(TotpAppListEvent.DialogDismissed)
}
@Test
fun givenTheMaxAppsDialog_whenIDisplayScreen_thenIExpectTheDialog() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached))
composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_MAX_APPS_REACHED).assertIsDisplayed()
}
@Test
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(TotpAppListEvent.LearnMoreClicked)
assertThat(events).contains(TotpAppListEvent.DialogDismissed)
}
@Test
fun whenTheListHasntArrived_thenIExpectASpinnerRatherThanNoTotpApps() {
setContent(TotpAppListState(loadState = LoadState.LOADING))
scrollTo(TotpAppListTestTags.LOADING)
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertIsDisplayed()
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist()
}
/** An account we couldn't ask about is not an account with no authenticator apps. */
@Test
fun givenTheListCouldntBeLoaded_whenIDisplayScreen_thenIExpectTheFailureMessageRatherThanTheEmptyOne() {
setContent(TotpAppListState(loadState = LoadState.NETWORK_FAILURE))
scrollTo(TotpAppListTestTags.LOAD_FAILED_MESSAGE)
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOAD_FAILED_MESSAGE).assertIsDisplayed()
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist()
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertDoesNotExist()
}
private fun setContent(state: TotpAppListState) {
composeTestRule.setContent {
TotpAppListScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}