BIT-956: UI for item listing screen (#356)

This commit is contained in:
Ramsey Smith
2023-12-11 12:39:24 -07:00
committed by GitHub
parent fe3c6c93e6
commit 6844f51faf
10 changed files with 1228 additions and 10 deletions

View File

@@ -0,0 +1,407 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import com.x8bit.bitwarden.R
class VaultItemListingScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToVaultAddItemScreenCalled = false
private var onNavigateToVaultItemId: String? = null
private val mutableEventFlow = MutableSharedFlow<VaultItemListingEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultItemListingViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
VaultItemListingScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultItem = { onNavigateToVaultItemId = it },
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
)
}
}
@Test
fun `NavigateBack event should invoke NavigateBack`() {
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `clicking back button should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription(label = "Back")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.BackClick) }
}
@Test
fun `search icon click should send SearchIconClick action`() {
composeTestRule
.onNodeWithContentDescription("Search vault")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.SearchIconClick) }
}
@Test
fun `floating action button click should send AddItemClick action`() {
composeTestRule
.onNodeWithContentDescription("Add item")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }
}
@Test
fun `add an item button click should send AddItemClick action`() {
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.NoItems) }
composeTestRule
.onNodeWithText("Add an Item")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }
}
@Test
fun `refresh button click should send RefreshClick action`() {
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.Error(message = "".asText()))
}
composeTestRule
.onNodeWithText("Try again")
.performClick()
verify { viewModel.trySendAction(VaultItemListingsAction.RefreshClick) }
}
@Test
fun `NavigateToAdd VaultItem event should call NavigateToVaultAddItemScreen`() {
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToAddVaultItem)
assertTrue(onNavigateToVaultAddItemScreenCalled)
}
@Test
fun `NavigateToVaultItem event should call NavigateToVaultItemScreen`() {
val id = "id4321"
mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToVaultItem(id = id))
assertEquals(id, onNavigateToVaultItemId)
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNode(isProgressBar)
.assertDoesNotExist()
}
@Test
fun `error text and retry should be displayed according to state`() {
val message = "error_message"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(message)
.assertIsNotDisplayed()
mutableStateFlow.update { it.copy(viewState = VaultItemListingState.ViewState.NoItems) }
composeTestRule
.onNodeWithText(message)
.assertIsNotDisplayed()
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.Error(message.asText()))
}
composeTestRule
.onNodeWithText(message)
.assertIsDisplayed()
}
@Test
fun `Add an item button should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Folder(folderId = null))
}
composeTestRule
.onNodeWithText(text = "Add an Item")
.assertDoesNotExist()
}
@Test
fun `empty text should be displayed according to state`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(viewState = VaultItemListingState.ViewState.NoItems)
}
composeTestRule
.onNodeWithText(text = "There are no items in your vault.")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithText(text = "There are no items in the trash.")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Folder(folderId = null))
}
composeTestRule
.onNodeWithText(text = "There are no items in this folder.")
.assertIsDisplayed()
}
@Test
fun `floating action button should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithContentDescription("Add item")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithContentDescription("Add item")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Folder(folderId = null))
}
composeTestRule
.onNodeWithContentDescription("Add item")
.assertDoesNotExist()
}
@Test
fun `Items text should be displayed according to state`() {
val items = "Items"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = items)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
}
@Test
fun `Items text count should be displayed according to state`() {
val items = "Items"
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = items)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
.assertTextEquals(items, 1.toString())
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
createDisplayItem(number = 2),
createDisplayItem(number = 3),
createDisplayItem(number = 4),
),
),
)
}
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasText(items))
composeTestRule
.onNodeWithText(text = items)
.assertIsDisplayed()
.assertTextEquals(items, 4.toString())
}
@Test
fun `displayItems should be displayed according to state`() {
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithText(text = "mockTitle-1")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "mockSubtitle-1")
.assertIsDisplayed()
}
@Test
fun `clicking on a display item should send ItemClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createDisplayItem(number = 1),
),
),
)
}
composeTestRule
.onNodeWithText(text = "mockTitle-1")
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(VaultItemListingsAction.ItemClick("mockId-1"))
}
}
@Test
fun `topBar title should be displayed according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText(text = "Logins")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.SecureNote)
}
composeTestRule
.onNodeWithText(text = "Secure notes")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Card)
}
composeTestRule
.onNodeWithText(text = "Cards")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Identity)
}
composeTestRule
.onNodeWithText(text = "Identities")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(itemListingType = VaultItemListingState.ItemListingType.Trash)
}
composeTestRule
.onNodeWithText(text = "Trash")
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(
itemListingType = VaultItemListingState.ItemListingType.Folder(
folderId = "mockId",
folderName = "mockName",
),
)
}
composeTestRule
.onNodeWithText(text = "mockName")
.assertIsDisplayed()
}
}
private val DEFAULT_STATE = VaultItemListingState(
itemListingType = VaultItemListingState.ItemListingType.Login,
viewState = VaultItemListingState.ViewState.Loading,
)
private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
VaultItemListingState.DisplayItem(
id = "mockId-$number",
title = "mockTitle-$number",
subtitle = "mockSubtitle-$number",
uri = "mockUri-$number",
iconRes = R.drawable.ic_card_item,
)

View File

@@ -0,0 +1,120 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultItemListingViewModelTest : BaseViewModelTest() {
private val initialState = createVaultItemListingState()
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
vaultItemListingType = VaultItemListingType.Login,
)
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.stateFlow.test {
assertEquals(
initialState, awaitItem(),
)
}
}
@Test
fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.BackClick)
assertEquals(VaultItemListingEvent.NavigateBack, awaitItem())
}
}
@Test
fun `SearchIconClick should emit NavigateToVaultSearchScreen`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.SearchIconClick)
assertEquals(VaultItemListingEvent.NavigateToVaultSearchScreen, awaitItem())
}
}
@Test
fun `ItemClick should emit NavigateToVaultItem`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.ItemClick(id = "mock"))
assertEquals(VaultItemListingEvent.NavigateToVaultItem(id = "mock"), awaitItem())
}
}
@Test
fun `AddVaultItemClick should emit NavigateToAddVaultItem`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.AddVaultItemClick)
assertEquals(VaultItemListingEvent.NavigateToAddVaultItem, awaitItem())
}
}
@Test
fun `RefreshClick should emit ShowToast`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.RefreshClick)
assertEquals(
VaultItemListingEvent.ShowToast("Not yet implemented".asText()),
awaitItem(),
)
}
}
private fun createSavedStateHandleWithVaultItemListingType(
vaultItemListingType: VaultItemListingType,
) = SavedStateHandle().apply {
set(
"vault_item_listing_type",
when (vaultItemListingType) {
is VaultItemListingType.Card -> "card"
is VaultItemListingType.Folder -> "folder"
is VaultItemListingType.Identity -> "identity"
is VaultItemListingType.Login -> "login"
is VaultItemListingType.SecureNote -> "secure_note"
is VaultItemListingType.Trash -> "trash"
},
)
set(
"id",
when (vaultItemListingType) {
is VaultItemListingType.Card -> null
is VaultItemListingType.Folder -> vaultItemListingType.folderId
is VaultItemListingType.Identity -> null
is VaultItemListingType.Login -> null
is VaultItemListingType.SecureNote -> null
is VaultItemListingType.Trash -> null
},
)
}
private fun createVaultItemListingViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
): VaultItemListingViewModel =
VaultItemListingViewModel(
savedStateHandle = savedStateHandle,
)
@Suppress("MaxLineLength")
private fun createVaultItemListingState(
itemListingType: VaultItemListingState.ItemListingType = VaultItemListingState.ItemListingType.Login,
viewState: VaultItemListingState.ViewState = VaultItemListingState.ViewState.Loading,
): VaultItemListingState =
VaultItemListingState(
itemListingType = itemListingType,
viewState = viewState,
)
}

View File

@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import org.junit.Assert.assertEquals
import org.junit.Test
class VaultItemListingTypeExtensionsTest {
@Suppress("MaxLineLength")
@Test
fun `toItemListingType should transform a VaultItemListingType into a VaultItemListingState ItemListingType`() {
val itemListingTypeList = listOf(
VaultItemListingType.Folder(folderId = "mock"),
VaultItemListingType.Trash,
)
val result = itemListingTypeList.map { it.toItemListingType() }
assertEquals(
listOf(
VaultItemListingState.ItemListingType.Folder(folderId = "mock"),
VaultItemListingState.ItemListingType.Trash,
),
result,
)
}
}