diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt index 6bbe44f985f0..3011c96f2cac 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt @@ -11,7 +11,8 @@ import org.wordpress.android.fluxc.persistence.entity.OrderEntity import javax.inject.Inject sealed class LoadOrdersResult { - data class Success(val orders: List) : LoadOrdersResult() + data class SuccessCache(val orders: List) : LoadOrdersResult() + data class SuccessRemote(val orders: List) : LoadOrdersResult() data class Error(val message: String) : LoadOrdersResult() } @@ -26,7 +27,9 @@ class WooPosOrdersDataSource @Inject constructor( } fun loadOrders(): Flow = flow { val cached = ordersCache.getAll() - emit(LoadOrdersResult.Success(cached)) + if (cached.isNotEmpty()) { + emit(LoadOrdersResult.SuccessCache(cached)) + } val result = restClient.fetchOrders( site = selectedSite.get(), @@ -43,11 +46,13 @@ class WooPosOrdersDataSource @Inject constructor( } else { val mapped = result.orders.toAppModels() ordersCache.setAll(mapped) - emit(LoadOrdersResult.Success(result.orders.toAppModels())) + emit(LoadOrdersResult.SuccessRemote(result.orders.toAppModels())) } } + fun clearCache() = ordersCache.clear() + private suspend fun List.toAppModels(): List = map { orderMapper.toAppModel(it) - } ?: emptyList() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt index 0ce21e64bb4f..c023859bb830 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -14,6 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -27,16 +32,16 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.hilt.navigation.compose.hiltViewModel import com.woocommerce.android.R -import com.woocommerce.android.extensions.formatToDDMMMYYYY -import com.woocommerce.android.model.Order import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosText import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosToolbar import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosSpacing import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTypography +import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState import com.woocommerce.android.ui.woopos.root.navigation.WooPosNavigationEvent +@OptIn(ExperimentalMaterialApi::class) @Composable fun WooPosOrdersScreen( onNavigationEvent: (WooPosNavigationEvent) -> Unit, @@ -48,19 +53,59 @@ fun WooPosOrdersScreen( BackHandler { onNavigationEvent(WooPosNavigationEvent.GoBack) } Row(modifier = Modifier.fillMaxSize()) { - Column( + OrdersList( + state = state, + onBackClicked = onBackClicked, + onRefresh = viewModel::onRefresh, + isRefreshing = state.pullToRefreshState == WooPosPullToRefreshState.Refreshing, + onOrderSelected = viewModel::onOrderSelected, modifier = Modifier .weight(0.3f) .fillMaxHeight() .background(MaterialTheme.colorScheme.surface) - ) { - WooPosToolbar( - titleText = stringResource(R.string.woopos_orders_title), - onBackClicked = onBackClicked, - ) + ) + + OrderDetails( + state = state, + modifier = Modifier + .weight(0.7f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun OrdersList( + state: WooPosOrdersState, + onBackClicked: () -> Unit, + onRefresh: () -> Unit, + isRefreshing: Boolean, + onOrderSelected: (Long) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + WooPosToolbar( + titleText = stringResource(R.string.woopos_orders_title), + onBackClicked = onBackClicked, + ) + + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = onRefresh + ) - when { - state.isLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh( + pullRefreshState, + enabled = state.pullToRefreshState != WooPosPullToRefreshState.Disabled + ) + ) { + when (state) { + is WooPosOrdersState.Loading -> { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally @@ -73,56 +118,77 @@ fun WooPosOrdersScreen( ) } } - state.error != null -> { + + is WooPosOrdersState.Error -> { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally ) { WooPosText( - text = state.error ?: stringResource(R.string.error_generic), + text = state.message, style = WooPosTypography.BodyMedium, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(WooPosSpacing.Large.value) ) } } - state.orders.isEmpty() -> { + + is WooPosOrdersState.Empty -> { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally ) { WooPosText( - text = "No Orders Found", + text = "No orders found", style = WooPosTypography.BodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(WooPosSpacing.Large.value) ) } } - else -> { + + is WooPosOrdersState.Content -> { WooPosOrdersListPaneScreen( - orders = state.orders, + items = state.items, selectedOrderId = state.selectedOrderId, - onOrderSelected = viewModel::onOrderSelected, + onOrderSelected = onOrderSelected, modifier = Modifier.fillMaxSize() ) } } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = WooPosSpacing.XSmall.value), + backgroundColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) } + } +} - WooPosOrdersDetailPaneScreen( - order = state.selectedOrder, - modifier = Modifier - .weight(0.7f) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - ) +@Composable +private fun OrderDetails( + state: WooPosOrdersState, + modifier: Modifier = Modifier +) { + val selectedItem: OrderItemViewState? = when (state) { + is WooPosOrdersState.Content -> state.items.firstOrNull { it.id == state.selectedOrderId } + else -> null } + + WooPosOrdersDetailPaneScreen( + selected = selectedItem, + modifier = modifier.fillMaxSize() + ) } @Composable fun WooPosOrdersListPaneScreen( - orders: List, + items: List, selectedOrderId: Long?, onOrderSelected: (Long) -> Unit, modifier: Modifier = Modifier @@ -131,14 +197,13 @@ fun WooPosOrdersListPaneScreen( modifier = modifier, contentPadding = PaddingValues(vertical = WooPosSpacing.XSmall.value) ) { - items(orders, key = { it.id }) { order -> - val isSelected = order.id == selectedOrderId + items(items, key = { it.id }) { item -> + val isSelected = item.id == selectedOrderId val background = if (isSelected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surface } - val foreground = if (isSelected) { MaterialTheme.colorScheme.onPrimaryContainer } else { @@ -150,7 +215,7 @@ fun WooPosOrdersListPaneScreen( .fillMaxWidth() .clip(MaterialTheme.shapes.medium) .background(background) - .clickable { onOrderSelected(order.id) } + .clickable { onOrderSelected(item.id) } .semantics { selected = isSelected } .padding( horizontal = WooPosSpacing.Medium.value, @@ -158,24 +223,13 @@ fun WooPosOrdersListPaneScreen( ), verticalAlignment = Alignment.Top ) { - Column( - verticalArrangement = Arrangement.spacedBy(WooPosSpacing.XSmall.value) - ) { - WooPosText( - "Order #${order.number}", - style = WooPosTypography.BodyMedium - ) - WooPosText( - text = order.dateCreated.formatToDDMMMYYYY(), - style = WooPosTypography.BodySmall, - color = foreground - ) + Column(verticalArrangement = Arrangement.spacedBy(WooPosSpacing.XSmall.value)) { + WooPosText(item.title, style = WooPosTypography.BodyMedium, color = foreground) + WooPosText(item.date, style = WooPosTypography.BodySmall, color = foreground) } - Spacer(Modifier.weight(1f)) - WooPosText( - text = "${order.total} ${order.currency}", + text = item.total, style = WooPosTypography.BodyMedium, modifier = Modifier.alignByBaseline() ) @@ -186,29 +240,22 @@ fun WooPosOrdersListPaneScreen( @Composable fun WooPosOrdersDetailPaneScreen( - order: Order?, + selected: OrderItemViewState?, modifier: Modifier = Modifier ) { - Column( - modifier = modifier.fillMaxSize() - ) { + Column(modifier = modifier.fillMaxSize()) { WooPosToolbar( - modifier = Modifier - .fillMaxWidth(), - titleText = "Order #${order?.number ?: "--"}", + modifier = Modifier.fillMaxWidth(), + titleText = selected?.title ?: "--", titleFontWeight = FontWeight.Bold ) - Column( modifier = Modifier .fillMaxSize() - .padding( - start = WooPosSpacing.Large.value, - end = WooPosSpacing.Large.value, - ) + .padding(start = WooPosSpacing.Large.value, end = WooPosSpacing.Large.value) ) { WooPosText( - text = "Orders details will be displayed here", + text = "Order details goes here", style = WooPosTypography.BodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -219,9 +266,5 @@ fun WooPosOrdersDetailPaneScreen( @WooPosPreview @Composable fun WooPosOrdersScreenPreview() { - WooPosTheme { - WooPosOrdersScreen( - onNavigationEvent = {} - ) - } + WooPosTheme { WooPosOrdersScreen(onNavigationEvent = {}) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt index a10ddfdab633..9187893c00dd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt @@ -1,13 +1,45 @@ package com.woocommerce.android.ui.woopos.orders -import com.woocommerce.android.model.Order - -data class WooPosOrdersState( - val orders: List = emptyList(), - val selectedOrderId: Long? = null, - val isLoading: Boolean = false, - val error: String? = null -) { - val selectedOrder: Order? - get() = selectedOrderId?.let { id -> orders.firstOrNull { it.id == id } } +import androidx.compose.runtime.Immutable +import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState +import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState + +@Immutable +data class OrderItemViewState( + val id: Long, + val title: String, + val date: String, + val total: String, + val isSelected: Boolean +) + +@Immutable +sealed class WooPosOrdersState { + abstract val pullToRefreshState: WooPosPullToRefreshState + + @Immutable + data class Content( + val items: List, + override val pullToRefreshState: WooPosPullToRefreshState, + val paginationState: WooPosPaginationState, + val selectedOrderId: Long? + ) : WooPosOrdersState() + + @Immutable + data class Error( + val message: String, + ) : WooPosOrdersState() { + override val pullToRefreshState: WooPosPullToRefreshState = WooPosPullToRefreshState.Disabled + } + + @Immutable + data object Loading : WooPosOrdersState() { + override val pullToRefreshState: WooPosPullToRefreshState = WooPosPullToRefreshState.Disabled + } + + @Immutable + data class Empty( + override val pullToRefreshState: WooPosPullToRefreshState = + WooPosPullToRefreshState.Enabled + ) : WooPosOrdersState() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt index 5a8d12c7bf3f..c616922c995c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt @@ -2,11 +2,14 @@ package com.woocommerce.android.ui.woopos.orders import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.woocommerce.android.extensions.formatToDDMMMYYYY +import com.woocommerce.android.model.Order +import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState +import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -15,46 +18,84 @@ class WooPosOrdersViewModel @Inject constructor( private val ordersDataSource: WooPosOrdersDataSource ) : ViewModel() { - private val _state = MutableStateFlow(WooPosOrdersState()) + private val _state = MutableStateFlow(WooPosOrdersState.Loading) val state: StateFlow = _state.asStateFlow() init { - refreshOrders() + loadOrders() } fun onOrderSelected(orderId: Long) { - _state.update { it.copy(selectedOrderId = orderId) } + val currentState = _state.value + if (currentState is WooPosOrdersState.Content) { + _state.value = currentState.copy( + items = currentState.items.map { it.copy(isSelected = it.id == orderId) }, + selectedOrderId = orderId + ) + } } - fun refreshOrders() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } + fun onRefresh() { + val currentState = _state.value + _state.value = when (currentState) { + is WooPosOrdersState.Content -> currentState.copy( + pullToRefreshState = WooPosPullToRefreshState.Refreshing + ) + + is WooPosOrdersState.Empty -> currentState.copy( + pullToRefreshState = WooPosPullToRefreshState.Refreshing + ) + + is WooPosOrdersState.Error -> currentState + is WooPosOrdersState.Loading -> currentState + } + + ordersDataSource.clearCache() + loadOrders() + } + private fun loadOrders() { + viewModelScope.launch { ordersDataSource.loadOrders().collect { result -> when (result) { is LoadOrdersResult.Error -> { - _state.update { - it.copy( - isLoading = false, - error = result.message - ) - } + _state.value = WooPosOrdersState.Error(message = result.message) + } + + is LoadOrdersResult.SuccessCache -> { + updateContentState(result.orders) } - is LoadOrdersResult.Success -> { - val list = result.orders - _state.update { prev -> - prev.copy( - isLoading = false, - orders = list, - selectedOrderId = prev.selectedOrderId?.takeIf { id -> - list.any { o -> o.id == id } - } ?: list.firstOrNull()?.id - ) + is LoadOrdersResult.SuccessRemote -> { + if (result.orders.isEmpty()) { + _state.value = WooPosOrdersState.Empty() + } else { + updateContentState(result.orders) } } } } } } + + private fun updateContentState(orders: List) { + val currentSelectedId = (_state.value as? WooPosOrdersState.Content)?.selectedOrderId + val newSelectedId = currentSelectedId?.takeIf { id -> orders.any { it.id == id } } + ?: orders.firstOrNull()?.id + + _state.value = WooPosOrdersState.Content( + items = orders.map { order -> + OrderItemViewState( + id = order.id, + title = "Order #${order.number}", + date = order.dateCreated.formatToDDMMMYYYY(), + total = "${order.total} ${order.currency}", + isSelected = order.id == newSelectedId + ) + }, + selectedOrderId = newSelectedId, + pullToRefreshState = WooPosPullToRefreshState.Enabled, + paginationState = WooPosPaginationState.None + ) + } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt index 8e30dbe90fa9..f8cc11a1a32b 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt @@ -27,8 +27,7 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class WooPosOrdersDataSourceTest { - @Rule - @JvmField + @Rule @JvmField val coroutinesTestRule = WooPosCoroutineTestRule() private val orderRestClient: OrderRestClient = mock() @@ -45,34 +44,26 @@ class WooPosOrdersDataSourceTest { ) @Test - fun `given cache and successful fetch, when loadOrders collected, then emits cache first then mapped network and stores in cache`() = runTest { - // GIVEN + fun `when cache has data and fetch succeeds, then emit SuccessCache then SuccessRemote and store mapped in cache`() = runTest { val cachedOrder = OrderTestUtils.generateTestOrder() whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder)) - // Network returns two entities - val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 1) - val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 2) - val entities = listOf( - e1 to emptyList(), - e2 to emptyList() - ) - - val firstOrder = OrderTestUtils.generateTestOrder() - val secondOrder = OrderTestUtils.generateTestOrder() - - whenever(orderMapper.toAppModel(e1)).thenReturn(firstOrder) - whenever(orderMapper.toAppModel(e2)).thenReturn(secondOrder) + val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 11) + val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 22) + val entities = listOf(e1 to emptyList(), e2 to emptyList()) + val mapped1 = OrderTestUtils.generateTestOrder() + val mapped2 = OrderTestUtils.generateTestOrder() + whenever(orderMapper.toAppModel(e1)).thenReturn(mapped1) + whenever(orderMapper.toAppModel(e2)).thenReturn(mapped2) val payload = WCOrderStore.FetchOrdersResponsePayload( site = siteModel, ordersWithMeta = entities ) - whenever( orderRestClient.fetchOrders( site = eq(siteModel), - count = eq(25), + count = eq(WooPosOrdersDataSource.POS_ORDERS_PAGE_SIZE), page = eq(1), orderBy = any(), sortOrder = any(), @@ -86,22 +77,19 @@ class WooPosOrdersDataSourceTest { // THEN assertThat(emissions).hasSize(2) - // First emission = cache - val first = emissions[0] - assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java) - assertThat((first as LoadOrdersResult.Success).orders).containsExactly(cachedOrder) - // Second emission = network mapped - val second = emissions[1] - assertThat(second).isInstanceOf(LoadOrdersResult.Success::class.java) - assertThat((second as LoadOrdersResult.Success).orders).containsExactly(firstOrder, secondOrder) + val first = emissions[0] as LoadOrdersResult.SuccessCache + assertThat(first.orders).containsExactly(cachedOrder) + + val second = emissions[1] as LoadOrdersResult.SuccessRemote + assertThat(second.orders).containsExactly(mapped1, mapped2) verify(selectedSite).get() verify(ordersCache).getAll() - verify(ordersCache).setAll(listOf(firstOrder, secondOrder)) + verify(ordersCache).setAll(listOf(mapped1, mapped2)) verify(orderRestClient).fetchOrders( site = eq(siteModel), - count = eq(25), + count = eq(WooPosOrdersDataSource.POS_ORDERS_PAGE_SIZE), page = eq(1), orderBy = any(), sortOrder = any(), @@ -111,8 +99,7 @@ class WooPosOrdersDataSourceTest { } @Test - fun `given cache and fetch error, when loadOrders collected, then emits cache then error without caching`() = runTest { - // GIVEN + fun `when cache has data and fetch fails, then emit SuccessCache then Error and do not store`() = runTest { val cachedOrder = OrderTestUtils.generateTestOrder() whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder)) @@ -120,16 +107,14 @@ class WooPosOrdersDataSourceTest { type = WCOrderStore.OrderErrorType.GENERIC_ERROR, message = "generic error" ) - val payload = WCOrderStore.FetchOrdersResponsePayload( error = orderError, site = siteModel ) - whenever( orderRestClient.fetchOrders( site = eq(siteModel), - count = eq(25), + count = eq(WooPosOrdersDataSource.POS_ORDERS_PAGE_SIZE), page = eq(1), orderBy = any(), sortOrder = any(), @@ -144,19 +129,17 @@ class WooPosOrdersDataSourceTest { // THEN assertThat(emissions).hasSize(2) - val first = emissions[0] - assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java) - assertThat((first as LoadOrdersResult.Success).orders).containsExactly(cachedOrder) + val first = emissions[0] as LoadOrdersResult.SuccessCache + assertThat(first.orders).containsExactly(cachedOrder) - val second = emissions[1] - assertThat(second).isInstanceOf(LoadOrdersResult.Error::class.java) - assertThat((second as LoadOrdersResult.Error).message).isEqualTo("generic error") + val second = emissions[1] as LoadOrdersResult.Error + assertThat(second.message).isEqualTo("generic error") verify(ordersCache).getAll() verify(ordersCache, never()).setAll(any()) verify(orderRestClient).fetchOrders( site = eq(siteModel), - count = eq(25), + count = eq(WooPosOrdersDataSource.POS_ORDERS_PAGE_SIZE), page = eq(1), orderBy = any(), sortOrder = any(), @@ -166,8 +149,7 @@ class WooPosOrdersDataSourceTest { } @Test - fun `given empty cache, when loadOrders collected, then forwards params including createdVia and emits empty then empty`() = runTest { - // GIVEN + fun `when cache empty and fetch returns empty, then SuccessRemote empty and store empty`() = runTest { whenever(ordersCache.getAll()).thenReturn(emptyList()) val payload = WCOrderStore.FetchOrdersResponsePayload( @@ -177,7 +159,7 @@ class WooPosOrdersDataSourceTest { whenever( orderRestClient.fetchOrders( site = eq(siteModel), - count = eq(25), + count = eq(WooPosOrdersDataSource.POS_ORDERS_PAGE_SIZE), page = eq(1), orderBy = any(), sortOrder = any(), @@ -186,26 +168,19 @@ class WooPosOrdersDataSourceTest { ) ).thenReturn(payload) - // WHEN val emissions = sut.loadOrders().toList(mutableListOf()) - // THEN - assertThat(emissions).hasSize(2) - - val first = emissions[0] - assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java) - assertThat((first as LoadOrdersResult.Success).orders).isEmpty() + assertThat(emissions).hasSize(1) - val second = emissions[1] - assertThat(second).isInstanceOf(LoadOrdersResult.Success::class.java) - assertThat((second as LoadOrdersResult.Success).orders).isEmpty() + val first = emissions[0] as LoadOrdersResult.SuccessRemote + assertThat(first.orders).isEmpty() verify(selectedSite).get() verify(ordersCache).getAll() verify(ordersCache).setAll(emptyList()) verify(orderRestClient).fetchOrders( site = eq(siteModel), - count = eq(25), + count = eq(WooPosOrdersDataSource.POS_ORDERS_PAGE_SIZE), page = eq(1), orderBy = any(), sortOrder = any(), diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt new file mode 100644 index 000000000000..295e69086985 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt @@ -0,0 +1,206 @@ +package com.woocommerce.android.ui.woopos.orders + +import com.woocommerce.android.model.Order +import com.woocommerce.android.ui.orders.OrderTestUtils +import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState +import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState +import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class WooPosOrdersViewModelTest { + + @Rule @JvmField + val coroutineTestRule = WooPosCoroutineTestRule() + + private val dataSource: WooPosOrdersDataSource = mock() + + private lateinit var viewModel: WooPosOrdersViewModel + + private fun order(id: Long = 1L): Order = OrderTestUtils.generateTestOrder(orderId = id) + + @Before + fun setUp() { + // GIVEN + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessCache(listOf(order(1), order(2)))) } + ) + } + + @Test + fun `given cache and network data, when init, then final state shows network content`() = runTest { + // GIVEN + val cached = listOf(order(1)) + val network = listOf(order(2), order(3)) + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit(LoadOrdersResult.SuccessCache(cached)) + emit(LoadOrdersResult.SuccessRemote(network)) + } + ) + + // WHEN + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + + // THEN + val state = viewModel.state.value + assertThat(state).isInstanceOf(WooPosOrdersState.Content::class.java) + val content = state as WooPosOrdersState.Content + assertThat(content.items.map { it.id }).containsExactly(2L, 3L) + assertThat(content.pullToRefreshState).isEqualTo(WooPosPullToRefreshState.Enabled) + assertThat(content.paginationState).isEqualTo(WooPosPaginationState.None) + verify(dataSource).loadOrders() + } + + @Test + fun `given empty cache and non-empty network, when init, then final state shows network content`() = runTest { + // GIVEN + val network = listOf(order(10)) + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit(LoadOrdersResult.SuccessCache(emptyList())) // cache → VM sets Loading + emit(LoadOrdersResult.SuccessRemote(network)) // remote → Content + } + ) + + // WHEN + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + + // THEN + val state = viewModel.state.value + assertThat(state).isInstanceOf(WooPosOrdersState.Content::class.java) + val content = state as WooPosOrdersState.Content + assertThat(content.items.map { it.id }).containsExactly(10L) + assertThat(content.pullToRefreshState).isEqualTo(WooPosPullToRefreshState.Enabled) + } + + @Test + fun `given empty cache and empty network, when init, then final state is Empty`() = runTest { + // GIVEN + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit(LoadOrdersResult.SuccessCache(emptyList())) // cache → Loading + emit(LoadOrdersResult.SuccessRemote(emptyList())) // remote → Empty + } + ) + + // WHEN + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + + // THEN + val state = viewModel.state.value + assertThat(state).isInstanceOf(WooPosOrdersState.Empty::class.java) + } + + @Test + fun `given data source error, when init, then state is Error`() = runTest { + // GIVEN + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.Error("boom")) } + ) + + // WHEN + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + + // THEN + val state = viewModel.state.value + assertThat(state).isInstanceOf(WooPosOrdersState.Error::class.java) + val error = state as WooPosOrdersState.Error + assertThat(error.message).isEqualTo("boom") + } + + @Test + fun `given initial content, when refresh, then clear cache and update with network result`() = runTest { + // GIVEN + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1)))) } + ) + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + val before = viewModel.state.value as WooPosOrdersState.Content + assertThat(before.items.map { it.id }).containsExactly(1L) + + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit(LoadOrdersResult.SuccessCache(emptyList())) + emit(LoadOrdersResult.SuccessRemote(listOf(order(5), order(6)))) + } + ) + + // WHEN + viewModel.onRefresh() + advanceUntilIdle() + + // THEN + val after = viewModel.state.value as WooPosOrdersState.Content + assertThat(after.items.map { it.id }).containsExactly(5L, 6L) + assertThat(after.pullToRefreshState).isEqualTo(WooPosPullToRefreshState.Enabled) + verify(dataSource).clearCache() + verify(dataSource, times(2)).loadOrders() // init + refresh + } + + @Test + fun `given orders loaded, when selecting an order, then selected id and flags update`() = runTest { + // GIVEN + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2), order(3)))) } + ) + + // WHEN + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + viewModel.onOrderSelected(3L) + advanceUntilIdle() + + // THEN + val state = viewModel.state.value as WooPosOrdersState.Content + assertThat(state.selectedOrderId).isEqualTo(3L) + val selectedFlags = state.items.associate { it.id to it.isSelected } + assertThat(selectedFlags[1L]).isFalse() + assertThat(selectedFlags[2L]).isFalse() + assertThat(selectedFlags[3L]).isTrue() + } + + @Test + fun `given selection removed after reload, when refreshing, then first item is auto selected`() = runTest { + // GIVEN + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(100), order(200)))) } + ) + viewModel = WooPosOrdersViewModel(dataSource) + advanceUntilIdle() + viewModel.onOrderSelected(200L) + advanceUntilIdle() + + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit(LoadOrdersResult.SuccessCache(emptyList())) + emit(LoadOrdersResult.SuccessRemote(listOf(order(300), order(400)))) + } + ) + + // WHEN + viewModel.onRefresh() + advanceUntilIdle() + + // THEN + val state = viewModel.state.value as WooPosOrdersState.Content + assertThat(state.items.map { it.id }).containsExactly(300L, 400L) + assertThat(state.selectedOrderId).isEqualTo(300L) + } +}