diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt index de63a40f02d4..136bd30e415f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt @@ -3,6 +3,7 @@ package org.wordpress.android.ui.newstats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,8 +18,10 @@ import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsSummaryResult import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import javax.inject.Inject @@ -30,7 +33,8 @@ class InsightsViewModel @Inject constructor( InsightsCardsConfigurationRepository, private val networkUtilsWrapper: NetworkUtilsWrapper, private val statsSummaryUseCase: StatsSummaryUseCase, - private val statsInsightsUseCase: StatsInsightsUseCase + private val statsInsightsUseCase: StatsInsightsUseCase, + private val statsTagsUseCase: StatsTagsUseCase ) : ViewModel() { private val _visibleCards = MutableStateFlow>( @@ -68,13 +72,20 @@ class InsightsViewModel @Inject constructor( val isDataRefreshing: StateFlow = _isDataRefreshing.asStateFlow() - @Volatile - private var isDataLoaded = false - - @Volatile - private var isDataLoading = false + private val isDataLoaded = AtomicBoolean(false) + private val isDataLoading = AtomicBoolean(false) + private val summaryFetched = AtomicBoolean(false) + private val insightsFetched = AtomicBoolean(false) + // Main-thread-confined: only accessed from + // viewModelScope (Dispatchers.Main). + private var fetchJob: Job? = null init { + viewModelScope.launch { + statsSummaryUseCase.clearCache() + statsInsightsUseCase.clearCache() + statsTagsUseCase.clearCache() + } checkNetworkStatus() loadConfiguration() observeConfigurationChanges() @@ -90,30 +101,45 @@ class InsightsViewModel @Inject constructor( // region Data fetching fun loadDataIfNeeded() { - if (isDataLoaded || isDataLoading) return - isDataLoading = true + if (isDataLoaded.get() || + !isDataLoading.compareAndSet(false, true) + ) return fetchData() } fun fetchData(forceRefresh: Boolean = false) { val siteId = resolvedSiteId() ?: run { - isDataLoading = false + isDataLoading.set(false) _isDataRefreshing.value = false return } - viewModelScope.launch { + val cards = _cardsToLoad.value + val shouldFetchSummary = cards.needsSummary() + val shouldFetchInsights = cards.needsInsights() + if (!shouldFetchSummary && !shouldFetchInsights) { + isDataLoading.set(false) + _isDataRefreshing.value = false + return + } + fetchJob = viewModelScope.launch { try { coroutineScope { - launch { - fetchSummary(siteId, forceRefresh) + if (shouldFetchSummary) { + launch { + fetchSummary(siteId, forceRefresh) + } } - launch { - fetchInsights(siteId, forceRefresh) + if (shouldFetchInsights) { + launch { + fetchInsights( + siteId, forceRefresh + ) + } } } - isDataLoaded = true + isDataLoaded.set(true) } finally { - isDataLoading = false + isDataLoading.set(false) _isDataRefreshing.value = false } } @@ -131,6 +157,9 @@ class InsightsViewModel @Inject constructor( val result = statsSummaryUseCase( siteId, forceRefresh ) + if (result is StatsSummaryResult.Success) { + summaryFetched.set(true) + } _summaryResult.emit(result) } catch (e: Exception) { if (e is CancellationException) throw e @@ -160,6 +189,9 @@ class InsightsViewModel @Inject constructor( val result = statsInsightsUseCase( siteId, forceRefresh ) + if (result is InsightsResult.Success) { + insightsFetched.set(true) + } _insightsResult.emit(result) } catch (e: Exception) { if (e is CancellationException) throw e @@ -178,8 +210,11 @@ class InsightsViewModel @Inject constructor( } fun refreshData() { - isDataLoaded = false - isDataLoading = true + fetchJob?.cancel() + isDataLoaded.set(false) + summaryFetched.set(false) + insightsFetched.set(false) + isDataLoading.set(true) _isDataRefreshing.value = true fetchData(forceRefresh = true) } @@ -224,7 +259,17 @@ class InsightsViewModel @Inject constructor( ) { _visibleCards.value = config.visibleCards _hiddenCards.value = config.computeHiddenCards() + val cards = config.visibleCards + val needsNewFetch = + (cards.needsSummary() && + !summaryFetched.get()) || + (cards.needsInsights() && + !insightsFetched.get()) _cardsToLoad.value = config.visibleCards + if (needsNewFetch) { + isDataLoaded.set(false) + loadDataIfNeeded() + } } fun removeCard(cardType: InsightsCardType) { @@ -287,4 +332,22 @@ class InsightsViewModel @Inject constructor( null } } + + companion object { + // TAGS_AND_CATEGORIES is intentionally absent + // from both checks: it has its own dedicated + // fetch path via StatsTagsUseCase in + // TagsAndCategoriesViewModel. + private fun List.needsSummary(): + Boolean = any { + it == InsightsCardType.ALL_TIME_STATS || + it == InsightsCardType.MOST_POPULAR_DAY + } + + private fun List.needsInsights(): + Boolean = any { + it == InsightsCardType.YEAR_IN_REVIEW || + it == InsightsCardType.MOST_POPULAR_TIME + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index b15367e2a18b..dd7f0f498369 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -279,7 +279,6 @@ private fun StatsTabContent( ) StatsTab.INSIGHTS -> InsightsTabContent() StatsTab.SUBSCRIBERS -> SubscribersTabContent() - else -> PlaceholderTabContent(tab) } } @@ -1227,14 +1226,8 @@ private fun InsightsTabContent( uiState = tagsAndCategoriesUiState, onShowAllClick = { - val items = - tagsAndCategoriesViewModel - .getDetailData() TagsAndCategoriesDetailActivity - .start( - context, - items - ) + .start(context) }, onRemoveCard = { insightsViewModel @@ -1357,65 +1350,6 @@ private fun AddInsightsCardBottomSheet( } } -@Composable -private fun NoConnectionContent( - onRetry: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_wifi_off_24px), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .padding(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.no_connection_error_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.no_connection_error_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = onRetry) { - Text(stringResource(R.string.retry)) - } - } - } -} - -@Composable -private fun PlaceholderTabContent(tab: StatsTab) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(text = "${stringResource(id = tab.titleResId)} - Coming Soon") - } -} - @Composable private fun StatsPeriodMenu( expanded: Boolean, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt index 49dc9bc69331..b1b701be8617 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt @@ -44,4 +44,8 @@ class StatsInsightsUseCase @Inject constructor( result } } + + suspend fun clearCache() { + mutex.withLock { cachedInsights = null } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt index bbdff27903eb..42d832ab36a1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt @@ -44,4 +44,8 @@ class StatsSummaryUseCase @Inject constructor( result } } + + suspend fun clearCache() { + mutex.withLock { cachedSummary = null } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt new file mode 100644 index 000000000000..5dc0b868e2e2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt @@ -0,0 +1,105 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatsTagsUseCase @Inject constructor( + private val statsRepository: StatsRepository, + private val accountStore: AccountStore +) { + private val mutex = Mutex() + private var cachedTags: + Triple? = null + + // In-flight request keyed by (siteId, max). + // Concurrent callers with the same params join + // the existing request instead of duplicating it. + private var inFlight: + Pair, + CompletableDeferred>? = null + + @Suppress("ReturnCount") + suspend operator fun invoke( + siteId: Long, + max: Int = DEFAULT_MAX_ITEMS, + forceRefresh: Boolean = false + ): TagsResult { + val token = accountStore.accessToken + if (token.isNullOrEmpty()) { + return TagsResult.Error("No access token") + } + statsRepository.init(token) + + val key = siteId to max + + // Under lock: check cache, then check/create + // an in-flight deferred. The actual network + // call runs outside the lock so concurrent + // callers with different params aren't blocked. + val (deferred, isOwner) = mutex.withLock { + val cached = cachedTags + if (!forceRefresh && + isCacheHit(cached, siteId, max) + ) { + return TagsResult.Success( + cached!!.third + ) + } + + val existing = inFlight + if (!forceRefresh && + existing != null && + existing.first == key + ) { + return@withLock existing.second to false + } + + val newDeferred = + CompletableDeferred() + inFlight = key to newDeferred + newDeferred to true + } + + if (isOwner) { + val result = statsRepository.fetchTags( + siteId = siteId, + max = max + ) + mutex.withLock { + if (result is TagsResult.Success) { + cachedTags = + Triple(siteId, max, result.data) + } + inFlight = null + } + deferred.complete(result) + } + + return deferred.await() + } + + suspend fun clearCache() { + mutex.withLock { + cachedTags = null + inFlight = null + } + } + + private fun isCacheHit( + cached: Triple?, + siteId: Long, + max: Int + ): Boolean = cached != null && + cached.first == siteId && + cached.second == max + + companion object { + private const val DEFAULT_MAX_ITEMS = 10 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt new file mode 100644 index 000000000000..9949fe3bc7c6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt @@ -0,0 +1,130 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException + +abstract class BaseTagsAndCategoriesViewModel( + private val selectedSiteRepository: + SelectedSiteRepository, + private val statsTagsUseCase: StatsTagsUseCase, + private val resourceProvider: ResourceProvider, + private val mapper: TagsAndCategoriesMapper +) : ViewModel() { + private val _uiState = + MutableStateFlow( + TagsAndCategoriesCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val isLoaded = AtomicBoolean(false) + private val isLoading = AtomicBoolean(false) + // Main-thread-confined: only accessed from + // viewModelScope (Dispatchers.Main). + private var fetchJob: Job? = null + + protected abstract val maxItems: Int + + fun loadData() { + if (isLoaded.get() || + !isLoading.compareAndSet(false, true) + ) return + fetchData() + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + protected fun fetchData( + forceRefresh: Boolean = false + ) { + val site = selectedSiteRepository + .getSelectedSite() + if (site == null) { + isLoading.set(false) + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + + fetchJob = viewModelScope.launch { + try { + val result = statsTagsUseCase( + siteId = site.siteId, + max = maxItems, + forceRefresh = forceRefresh + ) + isLoaded.set( + result is TagsResult.Success + ) + handleResult(result) + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching tags: ${e.message}", + e + ) + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + } finally { + isLoading.set(false) + } + } + } + + protected fun resetForRefresh() { + fetchJob?.cancel() + isLoaded.set(false) + isLoading.set(true) + _uiState.value = + TagsAndCategoriesCardUiState.Loading + } + + private fun handleResult(result: TagsResult) { + when (result) { + is TagsResult.Success -> { + val items = mapper.mapToUiItems( + result.data.tagGroups + ) + _uiState.value = + TagsAndCategoriesCardUiState.Loaded( + items = items, + maxViewsForBar = + items.firstOrNull() + ?.views ?: 1L + ) + } + is TagsResult.Error -> { + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_api + ) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt index bc2a3be620c5..c84ddc8cd1f8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt @@ -4,27 +4,37 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -34,41 +44,40 @@ import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.util.ShimmerBox private const val DETAIL_EXPANDED_START_PADDING = 52 +private const val LOADING_SHIMMER_ITEM_COUNT = 10 @AndroidEntryPoint class TagsAndCategoriesDetailActivity : BaseAppCompatActivity() { + private val viewModel: + TagsAndCategoriesDetailViewModel + by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val items = detailItems - if (items == null) { - finish() - return - } + viewModel.loadData() setContent { AppThemeM3 { + val uiState by viewModel.uiState + .collectAsState() TagsAndCategoriesDetailScreen( - items = items, - onBackPressed = onBackPressedDispatcher - ::onBackPressed + uiState = uiState, + onBackPressed = + onBackPressedDispatcher + ::onBackPressed, + onRetry = { viewModel.loadData() } ) } } } companion object { - private var detailItems: List? = - null - - fun start( - context: Context, - items: List - ) { - detailItems = items + fun start(context: Context) { val intent = Intent( context, TagsAndCategoriesDetailActivity::class @@ -76,29 +85,16 @@ class TagsAndCategoriesDetailActivity : ) context.startActivity(intent) } - - fun clearData() { - detailItems = null - } - } - - override fun onDestroy() { - super.onDestroy() - if (isFinishing) { - clearData() - } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TagsAndCategoriesDetailScreen( - items: List, - onBackPressed: () -> Unit + uiState: TagsAndCategoriesCardUiState, + onBackPressed: () -> Unit, + onRetry: () -> Unit ) { - val maxViews = - items.firstOrNull()?.views ?: 1L - Scaffold( topBar = { TopAppBar( @@ -127,76 +123,187 @@ private fun TagsAndCategoriesDetailScreen( ) } ) { contentPadding -> - val expandedGroups = remember { - mutableStateMapOf() + when (uiState) { + is TagsAndCategoriesCardUiState.Loading -> + DetailLoadingContent( + modifier = Modifier + .padding(contentPadding) + ) + is TagsAndCategoriesCardUiState.Loaded -> + DetailLoadedContent( + items = uiState.items, + maxViews = uiState.maxViewsForBar, + modifier = Modifier + .padding(contentPadding) + ) + is TagsAndCategoriesCardUiState.Error -> + DetailErrorContent( + message = uiState.message, + onRetry = onRetry, + modifier = Modifier + .padding(contentPadding) + ) + } + } +} + +@Composable +private fun DetailLoadingContent( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + ) { + StatsListHeader( + leftHeaderResId = + R.string + .stats_insights_tags_and_categories, + rightHeaderResId = + R.string.stats_views + ) + Spacer(modifier = Modifier.height(8.dp)) + repeat(LOADING_SHIMMER_ITEM_COUNT) { + Spacer(modifier = Modifier.height(12.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + ) } + } +} - LazyColumn( - modifier = Modifier +@Composable +private fun DetailLoadedContent( + items: List, + maxViews: Long, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) { + Box( + modifier = modifier .fillMaxSize() - .padding(contentPadding) - .padding(horizontal = 16.dp), - verticalArrangement = - Arrangement.spacedBy(4.dp) + .padding(16.dp), + contentAlignment = Alignment.Center ) { - item { - StatsListHeader( - leftHeaderResId = - R.string - .stats_insights_tags_and_categories, - rightHeaderResId = - R.string.stats_views - ) - Spacer( - modifier = Modifier.height(8.dp) - ) - } - itemsIndexed(items) { index, item -> - val percentage = - if (maxViews > 0) { - item.views.toFloat() / - maxViews.toFloat() - } else { - 0f - } - val isExpanded = - expandedGroups[index] == true + Text( + text = stringResource( + R.string + .stats_insights_tags_empty + ), + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + return + } - TagGroupRow( - item = item, - percentage = percentage, - position = index + 1, - isExpandable = item.isExpandable, - isExpanded = isExpanded, - onClick = if (item.isExpandable) { - { - expandedGroups[index] = - !isExpanded - } - } else { - null - } - ) - if (item.isExpandable) { - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically(), - exit = shrinkVertically() - ) { - ExpandedTagsSection( - tags = item.tags, - startPadding = - DETAIL_EXPANDED_START_PADDING - .dp - ) + val expandedGroups = remember { + mutableStateMapOf() + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = + Arrangement.spacedBy(4.dp) + ) { + item { + StatsListHeader( + leftHeaderResId = + R.string + .stats_insights_tags_and_categories, + rightHeaderResId = + R.string.stats_views + ) + Spacer( + modifier = Modifier.height(8.dp) + ) + } + itemsIndexed(items) { index, item -> + val percentage = + if (maxViews > 0) { + item.views.toFloat() / + maxViews.toFloat() + } else { + 0f + } + val isExpanded = + expandedGroups[index] == true + + TagGroupRow( + item = item, + percentage = percentage, + position = index + 1, + isExpandable = item.isExpandable, + isExpanded = isExpanded, + onClick = if (item.isExpandable) { + { + expandedGroups[index] = + !isExpanded } + } else { + null + } + ) + if (item.isExpandable) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + ExpandedTagsSection( + tags = item.tags, + startPadding = + DETAIL_EXPANDED_START_PADDING + .dp + ) } } - item { - Spacer( - modifier = Modifier.height(8.dp) + } + item { + Spacer( + modifier = Modifier.height(8.dp) + ) + } + } +} + +@Composable +private fun DetailErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = + Arrangement.Center + ) { + Text( + text = message, + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text( + text = stringResource( + R.string.retry ) - } + ) } } } @@ -206,33 +313,40 @@ private fun TagsAndCategoriesDetailScreen( private fun TagsAndCategoriesDetailPreview() { AppThemeM3 { TagsAndCategoriesDetailScreen( - items = listOf( - TagGroupUiItem( - name = "Uncategorized", - tags = listOf( - TagUiItem( + uiState = + TagsAndCategoriesCardUiState.Loaded( + items = listOf( + TagGroupUiItem( name = "Uncategorized", - tagType = "category" - ) - ), - views = 83, - displayType = - TagGroupDisplayType.CATEGORY - ), - TagGroupUiItem( - name = "snaps", - tags = listOf( - TagUiItem( + tags = listOf( + TagUiItem( + name = + "Uncategorized", + tagType = "category" + ) + ), + views = 83, + displayType = + TagGroupDisplayType + .CATEGORY + ), + TagGroupUiItem( name = "snaps", - tagType = "tag" + tags = listOf( + TagUiItem( + name = "snaps", + tagType = "tag" + ) + ), + views = 15, + displayType = + TagGroupDisplayType.TAG ) ), - views = 15, - displayType = - TagGroupDisplayType.TAG - ) - ), - onBackPressed = {} + maxViewsForBar = 83 + ), + onBackPressed = {}, + onRetry = {} ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt new file mode 100644 index 000000000000..aec0d51b6496 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import dagger.hilt.android.lifecycle.HiltViewModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +@HiltViewModel +class TagsAndCategoriesDetailViewModel @Inject constructor( + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper +) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper +) { + override val maxItems: Int = DETAIL_MAX_ITEMS + + companion object { + private const val DETAIL_MAX_ITEMS = 100 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt new file mode 100644 index 000000000000..93620da3f08b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import javax.inject.Inject + +class TagsAndCategoriesMapper @Inject constructor() { + fun mapToUiItems( + tagGroups: List + ): List = tagGroups.map { group -> + val tagUiItems = group.tags.map { tag -> + TagUiItem( + name = tag.name, + tagType = tag.tagType + ) + } + TagGroupUiItem( + name = tagUiItems.joinToString( + TAGS_SEPARATOR + ) { it.name }, + tags = tagUiItems, + views = group.views, + displayType = + TagGroupDisplayType.fromTags(tagUiItems) + ) + } + + companion object { + private const val TAGS_SEPARATOR = " / " + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt index c616f6557ddf..25f71c85b5f4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt @@ -1,154 +1,31 @@ package org.wordpress.android.ui.newstats.tagsandcategories -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.wordpress.android.R -import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.repository.StatsRepository -import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.viewmodel.ResourceProvider -import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @HiltViewModel class TagsAndCategoriesViewModel @Inject constructor( - private val selectedSiteRepository: - SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider -) : ViewModel() { - private val _uiState = - MutableStateFlow( - TagsAndCategoriesCardUiState.Loading - ) - val uiState: StateFlow = - _uiState.asStateFlow() - - private var allItems: List = emptyList() - private val isLoaded = AtomicBoolean(false) - private val isLoading = AtomicBoolean(false) - private var fetchJob: Job? = null - - fun loadData() { - if (isLoaded.get() || !isLoading.compareAndSet(false, true)) return - fetchData() - } + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper +) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper +) { + override val maxItems: Int = CARD_MAX_ITEMS fun refresh() { - fetchJob?.cancel() - isLoaded.set(false) - isLoading.set(true) - _uiState.value = TagsAndCategoriesCardUiState.Loading - fetchData() - } - - fun getDetailData(): List = allItems - - @Suppress("TooGenericExceptionCaught") - private fun fetchData() { - val site = selectedSiteRepository - .getSelectedSite() - if (site == null) { - isLoading.set(false) - _uiState.value = - TagsAndCategoriesCardUiState.Error( - resourceProvider.getString( - R.string.stats_error_no_site - ) - ) - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading.set(false) - _uiState.value = - TagsAndCategoriesCardUiState.Error( - resourceProvider.getString( - R.string.stats_error_api - ) - ) - return - } - - statsRepository.init(accessToken) - - fetchJob = viewModelScope.launch { - try { - val result = statsRepository.fetchTags( - siteId = site.siteId - ) - isLoaded.set(result is TagsResult.Success) - handleResult(result) - } catch (e: Exception) { - _uiState.value = - TagsAndCategoriesCardUiState.Error( - e.message ?: resourceProvider - .getString( - R.string.stats_error_unknown - ) - ) - } finally { - isLoading.set(false) - } - } - } - - private fun handleResult(result: TagsResult) { - when (result) { - is TagsResult.Success -> { - val items = result.data.tagGroups - .map { group -> - val tagUiItems = group.tags - .map { tag -> - TagUiItem( - name = tag.name, - tagType = tag.tagType - ) - } - TagGroupUiItem( - name = tagUiItems.joinToString( - TAGS_SEPARATOR - ) { it.name }, - tags = tagUiItems, - views = group.views, - displayType = - TagGroupDisplayType - .fromTags(tagUiItems) - ) - } - allItems = items - val cardItems = - items.take(CARD_MAX_ITEMS) - _uiState.value = - TagsAndCategoriesCardUiState.Loaded( - items = cardItems, - maxViewsForBar = - cardItems.firstOrNull() - ?.views ?: 1L - ) - } - is TagsResult.Error -> { - _uiState.value = - TagsAndCategoriesCardUiState.Error( - resourceProvider.getString( - R.string.stats_error_api - ) - ) - } - } + resetForRefresh() + fetchData(forceRefresh = true) } companion object { private const val CARD_MAX_ITEMS = 7 - private const val TAGS_SEPARATOR = " / " } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt index 37e1e951a117..29ab2103c0cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -1,12 +1,7 @@ package org.wordpress.android.ui.newstats.yearinreview import androidx.annotation.StringRes -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import org.wordpress.android.ui.newstats.util.rememberShimmerBrush import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -30,12 +25,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -112,42 +104,13 @@ fun YearInReviewCard( @Composable private fun LoadingContent() { - val shimmerColors = listOf( - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.3f), - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.6f), - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.3f) - ) - - val transition = - rememberInfiniteTransition(label = "shimmer") - val translateAnimation by transition.animateFloat( - initialValue = 0f, - targetValue = 1000f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1200, - easing = LinearEasing - ), - repeatMode = RepeatMode.Restart - ), - label = "shimmer_translate" - ) - - val shimmerBrush = Brush.linearGradient( - colors = shimmerColors, - start = Offset(translateAnimation - 500f, 0f), - end = Offset(translateAnimation, 0f) - ) + val shimmerBrush = rememberShimmerBrush() Column( modifier = Modifier .fillMaxWidth() .padding(CardPadding) ) { - // Title shimmer Box( modifier = Modifier .width(140.dp) @@ -156,7 +119,6 @@ private fun LoadingContent() { .background(shimmerBrush) ) Spacer(modifier = Modifier.height(16.dp)) - // 2x2 grid shimmer repeat(2) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt index 4a457c8d8aad..b43a8ef055b2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt @@ -209,5 +209,4 @@ internal fun PostStatus?.toLabel(): Int = when (this) { is PostStatus.Any -> 0 is PostStatus.Custom -> 0 null -> 0 - else -> 0 } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 0e292ad7a4a4..6b3781a272e2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1543,6 +1543,7 @@ %1$s%% of views Most popular time Tags and Categories + No tags or categories found Category Tag Expand diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt index 7508b02f36e7..c36615145ddf 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt @@ -13,6 +13,7 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest @@ -25,8 +26,10 @@ import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsSummaryResult import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.util.NetworkUtilsWrapper +@Suppress("LargeClass") @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner.Silent::class) class InsightsViewModelTest : @@ -51,6 +54,10 @@ class InsightsViewModelTest : private lateinit var statsInsightsUseCase: StatsInsightsUseCase + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + private lateinit var viewModel: InsightsViewModel private val testSite = SiteModel().apply { @@ -90,7 +97,8 @@ class InsightsViewModelTest : cardConfigurationRepository, networkUtilsWrapper, statsSummaryUseCase, - statsInsightsUseCase + statsInsightsUseCase, + statsTagsUseCase ) } @@ -238,7 +246,8 @@ class InsightsViewModelTest : cardConfigurationRepository, networkUtilsWrapper, statsSummaryUseCase, - statsInsightsUseCase + statsInsightsUseCase, + statsTagsUseCase ) advanceUntilIdle() @@ -353,7 +362,8 @@ class InsightsViewModelTest : cardConfigurationRepository, networkUtilsWrapper, statsSummaryUseCase, - statsInsightsUseCase + statsInsightsUseCase, + statsTagsUseCase ) assertThat(viewModel.cardsToLoad.value) @@ -490,10 +500,10 @@ class InsightsViewModelTest : advanceUntilIdle() verify(statsSummaryUseCase, - org.mockito.Mockito.times(1)) + times(1)) .invoke(any(), any()) verify(statsInsightsUseCase, - org.mockito.Mockito.times(1)) + times(1)) .invoke(any(), any()) } @@ -625,13 +635,16 @@ class InsightsViewModelTest : @Test fun `when no site selected, then fetchData is no-op`() = test { - initViewModel() - advanceUntilIdle() - whenever( selectedSiteRepository.getSelectedSite() ).thenReturn(null) + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + initViewModel(config) + advanceUntilIdle() + viewModel.fetchData() advanceUntilIdle() @@ -639,6 +652,231 @@ class InsightsViewModelTest : .invoke(any(), any()) } + @Test + fun `when all cards hidden, then no endpoints are called`() = + test { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + verify(statsInsightsUseCase, never()) + .invoke(any(), any()) + } + + @Test + fun `when only summary cards visible, then only summary is fetched`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.ALL_TIME_STATS, + InsightsCardType.MOST_POPULAR_DAY + ) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsInsightsUseCase, never()) + .invoke(any(), any()) + } + + @Test + fun `when only insights cards visible, then only insights is fetched`() = + test { + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.MOST_POPULAR_TIME + ) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsInsightsUseCase) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + } + + @Test + fun `when hidden card re-added, then its endpoint is fetched`() = + test { + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + + // Start with only insights cards + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW + ) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsInsightsUseCase, + times(1)) + .invoke(any(), any()) + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + + // Now add a summary card via config change + val newConfig = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.ALL_TIME_STATS + ) + ) + configurationFlow.value = + TEST_SITE_ID to newConfig + advanceUntilIdle() + + // loadDataIfNeeded should now fetch + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(false)) + } + + @Test + fun `when refresh called, then all visible endpoints are re-fetched`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + verify(statsSummaryUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsSummaryUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(true)) + verify(statsInsightsUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsInsightsUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(true)) + } + + @Test + fun `when refresh called twice rapidly, then second refresh replaces first`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + // Two rapid refreshes — second should + // cancel the first via fetchJob?.cancel(). + viewModel.refreshData() + viewModel.refreshData() + advanceUntilIdle() + + // The second refreshData() cancels the + // first job before it executes, so only + // one forceRefresh=true call completes. + assertThat( + viewModel.isDataRefreshing.value + ).isFalse() + + verify(statsSummaryUseCase, times(1)) + .invoke(eq(TEST_SITE_ID), eq(true)) + } + + @Test + fun `when initialized, then all caches are cleared`() = + test { + initViewModel() + advanceUntilIdle() + + verify(statsSummaryUseCase).clearCache() + verify(statsInsightsUseCase).clearCache() + verify(statsTagsUseCase).clearCache() + } + // endregion companion object { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt index 82038fee50a7..5d188df4edd4 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt @@ -2,6 +2,7 @@ package org.wordpress.android.ui.newstats.mostpopularday import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat +import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -11,6 +12,7 @@ import org.wordpress.android.R import org.wordpress.android.ui.newstats.datasource.StatsSummaryData import org.wordpress.android.ui.newstats.repository.StatsSummaryResult import org.wordpress.android.viewmodel.ResourceProvider +import java.util.Locale @ExperimentalCoroutinesApi class MostPopularDayViewModelTest : BaseUnitTest() { @@ -21,8 +23,12 @@ class MostPopularDayViewModelTest : BaseUnitTest() { private lateinit var viewModel: MostPopularDayViewModel + private lateinit var originalLocale: Locale + @Before fun setUp() { + originalLocale = Locale.getDefault() + Locale.setDefault(Locale.US) lenient().`when`( resourceProvider.getString( R.string.stats_error_api @@ -33,6 +39,11 @@ class MostPopularDayViewModelTest : BaseUnitTest() { ) } + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + @Test fun `initial state is Loading`() { assertThat(viewModel.uiState.value) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt index f3d13be72636..7fef1e148089 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt @@ -439,6 +439,132 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { ) } + @Test + fun `when moveCardUp on middle card, then card swaps with previous`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardUp( + TEST_SITE_ID, + InsightsCardType.ALL_TIME_STATS + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards[0]) + .isEqualTo(InsightsCardType.ALL_TIME_STATS) + assertThat(saved.visibleCards[1]) + .isEqualTo(InsightsCardType.YEAR_IN_REVIEW) + } + + @Test + fun `when moveCardDown on middle card, then card swaps with next`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardDown( + TEST_SITE_ID, + InsightsCardType.ALL_TIME_STATS + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards[1]) + .isEqualTo( + InsightsCardType.MOST_POPULAR_DAY + ) + assertThat(saved.visibleCards[2]) + .isEqualTo(InsightsCardType.ALL_TIME_STATS) + } + + @Test + fun `when moveCardToTop on middle card, then card becomes first`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardToTop( + TEST_SITE_ID, + InsightsCardType.MOST_POPULAR_DAY + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards[0]) + .isEqualTo( + InsightsCardType.MOST_POPULAR_DAY + ) + } + + @Test + fun `when moveCardToBottom on middle card, then card becomes last`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardToBottom( + TEST_SITE_ID, + InsightsCardType.MOST_POPULAR_DAY + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards.last()) + .isEqualTo( + InsightsCardType.MOST_POPULAR_DAY + ) + } + companion object { private const val TEST_SITE_ID = 123L private val ALL_CARDS_JSON = """ diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt index 22b41d2fce86..fffa9fac7450 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt @@ -202,6 +202,27 @@ class StatsInsightsUseCaseTest : BaseUnitTest() { assertThat(success.data.years).hasSize(1) } + @Test + fun `when clearCache called, then next call fetches again`() = + test { + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + useCase(TEST_SITE_ID) + useCase.clearCache() + useCase(TEST_SITE_ID) + + verify(statsRepository, times(2)) + .fetchInsights(TEST_SITE_ID) + } + private fun createTestInsightsData() = StatsInsightsData( highestHour = TEST_HIGHEST_HOUR, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt index b4181e5ffaa1..dbd6d461de2a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt @@ -136,6 +136,27 @@ class StatsSummaryUseCaseTest : BaseUnitTest() { .fetchStatsSummary(TEST_SITE_ID) } + @Test + fun `when clearCache called, then next call fetches again`() = + test { + whenever( + statsRepository.fetchStatsSummary( + TEST_SITE_ID + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummary() + ) + ) + + useCase(TEST_SITE_ID) + useCase.clearCache() + useCase(TEST_SITE_ID) + + verify(statsRepository, times(2)) + .fetchStatsSummary(TEST_SITE_ID) + } + private fun createTestSummary() = StatsSummaryData( views = 100L, visitors = 50L, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt new file mode 100644 index 000000000000..ed75d4e88a98 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt @@ -0,0 +1,182 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.StatsTagsData + +@ExperimentalCoroutinesApi +class StatsTagsUseCaseTest : BaseUnitTest() { + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var accountStore: AccountStore + + private lateinit var useCase: StatsTagsUseCase + + @Before + fun setUp() { + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + useCase = StatsTagsUseCase( + statsRepository, + accountStore + ) + } + + @Test + fun `when called, then returns cached on second call`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + val first = useCase(TEST_SITE_ID) + val second = useCase(TEST_SITE_ID) + + assertThat(first).isInstanceOf( + TagsResult.Success::class.java + ) + assertThat(second).isInstanceOf( + TagsResult.Success::class.java + ) + verify(statsRepository, times(1)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + @Test + fun `when called with forceRefresh, then fetches again`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + useCase(TEST_SITE_ID) + useCase( + TEST_SITE_ID, + forceRefresh = true + ) + + verify(statsRepository, times(2)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + @Test + fun `when called without token, then returns error`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) + useCase = StatsTagsUseCase( + statsRepository, + accountStore + ) + + val result = useCase(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + TagsResult.Error::class.java + ) + verify(statsRepository, never()) + .fetchTags( + siteId = any(), + max = any() + ) + } + + @Test + fun `when clearCache called, then next call fetches again`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + useCase(TEST_SITE_ID) + useCase.clearCache() + useCase(TEST_SITE_ID) + + verify(statsRepository, times(2)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + @Test + fun `when errors, then cache is not populated`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Error("Network error") + ) + + val first = useCase(TEST_SITE_ID) + assertThat(first).isInstanceOf( + TagsResult.Error::class.java + ) + + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + val second = useCase(TEST_SITE_ID) + assertThat(second).isInstanceOf( + TagsResult.Success::class.java + ) + verify(statsRepository, times(2)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + private fun createTestTagsData() = StatsTagsData( + tagGroups = emptyList() + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val DEFAULT_MAX = 10 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt new file mode 100644 index 000000000000..6086287dfb9e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt @@ -0,0 +1,496 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.TagData +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.viewmodel.ResourceProvider + +/** + * Tests for [BaseTagsAndCategoriesViewModel] using + * a concrete test subclass. + */ +@ExperimentalCoroutinesApi +class BaseTagsAndCategoriesViewModelTest : + BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + + @Mock + private lateinit var resourceProvider: + ResourceProvider + + private val mapper = TagsAndCategoriesMapper() + + private lateinit var viewModel: + TestTagsAndCategoriesViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + } + + private fun initViewModel( + maxItems: Int = TEST_MAX_ITEMS + ) { + viewModel = TestTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper, + maxItems + ) + } + + // region Initial state + + @Test + fun `initial state is Loading`() { + initViewModel() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + + // endregion + + // region loadData guard + + @Test + fun `when loadData called twice, then fetch once`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, times(1)) + .invoke(any(), any(), any()) + } + + @Test + fun `when loadData after success, then no refetch`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, times(1)) + .invoke(any(), any(), any()) + } + + @Test + fun `when loadData after error, then retries`() = + test { + stubApiError() + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + + // endregion + + // region Error states + + @Test + fun `when no site, then error state`() = + test { + stubNoSiteError() + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(NO_SITE_ERROR) + } + + @Test + fun `when no site, then use case not called`() = + test { + stubNoSiteError() + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, never()) + .invoke(any(), any(), any()) + } + + @Test + fun `when fetch returns error, then error state`() = + test { + stubApiError() + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(API_ERROR) + } + + @Test + fun `when exception thrown, then error state`() = + test { + stubUnknownError() + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenThrow( + RuntimeException("Test exception") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(UNKNOWN_ERROR) + } + + // endregion + + // region Success states + + @Test + fun `when fetch succeeds, then loaded state`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when fetch succeeds, then items mapped`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).hasSize(2) + assertThat(state.items[0].name) + .isEqualTo(TEST_CATEGORY_NAME) + assertThat(state.items[0].views) + .isEqualTo(TEST_CATEGORY_VIEWS) + } + + @Test + fun `when empty result, then loaded empty list`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = emptyList() + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).isEmpty() + assertThat(state.maxViewsForBar) + .isEqualTo(1L) + } + + @Test + fun `maxViewsForBar equals first item views`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.maxViewsForBar) + .isEqualTo(TEST_CATEGORY_VIEWS) + } + + // endregion + + // region maxItems passed to use case + + @Test + fun `maxItems is passed to use case`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel(maxItems = 42) + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase).invoke( + eq(TEST_SITE_ID), eq(42), any() + ) + } + + // endregion + + // region resetForRefresh + + @Test + fun `resetForRefresh sets loading state`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + + viewModel.callResetForRefresh() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + + @Test + fun `resetForRefresh allows refetch`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + viewModel.callResetForRefresh() + viewModel.callFetchData(forceRefresh = true) + advanceUntilIdle() + + verify(statsTagsUseCase, times(2)) + .invoke(any(), any(), any()) + } + + // endregion + + // region Helpers + + private fun stubNoSiteError() { + whenever( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn(NO_SITE_ERROR) + } + + private fun stubApiError() { + whenever( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(API_ERROR) + } + + private fun stubUnknownError() { + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(UNKNOWN_ERROR) + } + + private fun createSuccessResult() = + TagsResult.Success( + StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = TEST_CATEGORY_NAME + ) + ), + views = TEST_CATEGORY_VIEWS + ), + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = TEST_TAG_NAME + ) + ), + views = TEST_TAG_VIEWS + ) + ) + ) + ) + + // endregion + + /** + * Concrete subclass exposing protected methods + * for testing. + */ + private class TestTagsAndCategoriesViewModel( + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper, + override val maxItems: Int + ) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper + ) { + fun callResetForRefresh() = resetForRefresh() + fun callFetchData( + forceRefresh: Boolean = false + ) = fetchData(forceRefresh) + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_MAX_ITEMS = 10 + private const val NO_SITE_ERROR = + "No site selected" + private const val API_ERROR = + "Failed to load stats" + private const val UNKNOWN_ERROR = + "An unknown error occurred" + private const val TEST_CATEGORY_NAME = + "Uncategorized" + private const val TEST_CATEGORY_VIEWS = 83L + private const val TEST_TAG_NAME = "snaps" + private const val TEST_TAG_VIEWS = 15L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt new file mode 100644 index 000000000000..af222bb6e138 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt @@ -0,0 +1,120 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.TagData +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.viewmodel.ResourceProvider + +/** + * Tests specific to [TagsAndCategoriesDetailViewModel]. + * Base ViewModel behaviour (loading, errors, guards) is + * covered by [BaseTagsAndCategoriesViewModelTest]. + */ +@ExperimentalCoroutinesApi +class TagsAndCategoriesDetailViewModelTest : + BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + + @Mock + private lateinit var resourceProvider: + ResourceProvider + + private val mapper = TagsAndCategoriesMapper() + + private lateinit var viewModel: + TagsAndCategoriesDetailViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + } + + private fun initViewModel() { + viewModel = TagsAndCategoriesDetailViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper + ) + } + + @Test + fun `initial state is Loading`() { + initViewModel() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + + @Test + fun `when loadData, then fetches with detail max`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase) + .invoke( + eq(TEST_SITE_ID), + eq(DETAIL_MAX_ITEMS), + any() + ) + } + + private fun createSuccessResult() = + TagsResult.Success( + StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = "Uncategorized" + ) + ), + views = 83L + ) + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val DETAIL_MAX_ITEMS = 100 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt index 6c23d13b06f5..17dd50a6490a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt @@ -13,12 +13,11 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.datasource.StatsTagsData import org.wordpress.android.ui.newstats.datasource.TagData import org.wordpress.android.ui.newstats.datasource.TagGroupData -import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.ui.newstats.repository.TagsResult import org.wordpress.android.viewmodel.ResourceProvider @@ -29,14 +28,13 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { SelectedSiteRepository @Mock - private lateinit var accountStore: AccountStore - - @Mock - private lateinit var statsRepository: StatsRepository + private lateinit var statsTagsUseCase: StatsTagsUseCase @Mock private lateinit var resourceProvider: ResourceProvider + private val mapper = TagsAndCategoriesMapper() + private lateinit var viewModel: TagsAndCategoriesViewModel @@ -51,8 +49,6 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { whenever( selectedSiteRepository.getSelectedSite() ).thenReturn(testSite) - whenever(accountStore.accessToken) - .thenReturn(TEST_ACCESS_TOKEN) } private fun stubNoSiteError() { @@ -71,12 +67,20 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ).thenReturn(API_ERROR) } + private fun stubUnknownError() { + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(UNKNOWN_ERROR) + } + private fun initViewModel() { viewModel = TagsAndCategoriesViewModel( selectedSiteRepository, - accountStore, - statsRepository, - resourceProvider + statsTagsUseCase, + resourceProvider, + mapper ) } @@ -117,56 +121,12 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ).isEqualTo(NO_SITE_ERROR) } - @Test - fun `when access token is null, then error state`() = - test { - stubApiError() - whenever(accountStore.accessToken) - .thenReturn(null) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - TagsAndCategoriesCardUiState - .Error::class.java - ) - assertThat( - (state as TagsAndCategoriesCardUiState - .Error).message - ).isEqualTo(API_ERROR) - } - - @Test - fun `when access token is empty, then error state`() = - test { - stubApiError() - whenever(accountStore.accessToken) - .thenReturn("") - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - TagsAndCategoriesCardUiState - .Error::class.java - ) - assertThat( - (state as TagsAndCategoriesCardUiState - .Error).message - ).isEqualTo(API_ERROR) - } - @Test fun `when fetch returns error, then error state`() = test { stubApiError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Error("Network error") ) @@ -183,10 +143,11 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { } @Test - fun `when exception is thrown, then error state with message`() = + fun `when exception is thrown, then error state with localized message`() = test { + stubUnknownError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenThrow( RuntimeException("Test exception") ) @@ -203,7 +164,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { assertThat( (state as TagsAndCategoriesCardUiState .Error).message - ).isEqualTo("Test exception") + ).isEqualTo(UNKNOWN_ERROR) } // endregion @@ -212,7 +173,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when fetch succeeds, then loaded state`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -230,7 +191,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when fetch succeeds, then items are mapped correctly`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -254,7 +215,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when fetch succeeds, then maxViewsForBar is first item views`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -267,37 +228,6 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { .isEqualTo(TEST_CATEGORY_VIEWS) } - @Test - fun `when more than 7 items, then card shows only 7`() = - test { - val manyGroups = (1..10).map { i -> - TagGroupData( - tags = listOf( - TagData( - tagType = "tag", - name = "Tag $i" - ) - ), - views = (100 - i).toLong() - ) - } - whenever( - statsRepository.fetchTags(any(), any()) - ).thenReturn( - TagsResult.Success( - StatsTagsData(tagGroups = manyGroups) - ) - ) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val state = viewModel.uiState.value - as TagsAndCategoriesCardUiState.Loaded - assertThat(state.items).hasSize(CARD_MAX_ITEMS) - } - @Test fun `when multi-tag group, then name is joined with separator`() = test { @@ -315,7 +245,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -352,7 +282,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -384,7 +314,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -420,7 +350,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -443,7 +373,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when empty result, then loaded with empty list`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -459,7 +389,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value as TagsAndCategoriesCardUiState.Loaded assertThat(state.items).isEmpty() - assertThat(state.maxViewsForBar).isEqualTo(1L) + assertThat(state.maxViewsForBar) + .isEqualTo(1L) } // endregion @@ -468,7 +399,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when loadData called twice, then fetch only once`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -477,8 +408,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { viewModel.loadData() advanceUntilIdle() - verify(statsRepository, times(1)) - .fetchTags(eq(TEST_SITE_ID), any()) + verify(statsTagsUseCase, times(1)) + .invoke(eq(TEST_SITE_ID), any(), any()) } // endregion @@ -487,7 +418,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when refresh, then data is re-fetched`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -496,8 +427,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { viewModel.refresh() advanceUntilIdle() - verify(statsRepository, times(2)) - .fetchTags(eq(TEST_SITE_ID), any()) + verify(statsTagsUseCase, times(2)) + .invoke(eq(TEST_SITE_ID), any(), any()) } @Test @@ -505,7 +436,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { test { stubApiError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Error("Network error") ) @@ -521,7 +452,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) viewModel.refresh() @@ -537,11 +468,11 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { // region refresh sets loading @Test - fun `when refresh with no site, then loading then error`() = + fun `when refresh with no site, then error`() = test { stubNoSiteError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -568,7 +499,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { test { stubApiError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Error("Network error") ) @@ -584,7 +515,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) viewModel.loadData() @@ -598,65 +529,6 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData, then returns all items`() = - test { - val manyGroups = (1..10).map { i -> - TagGroupData( - tags = listOf( - TagData( - tagType = "tag", - name = "Tag $i" - ) - ), - views = (100 - i).toLong() - ) - } - whenever( - statsRepository.fetchTags(any(), any()) - ).thenReturn( - TagsResult.Success( - StatsTagsData(tagGroups = manyGroups) - ) - ) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - assertThat(detailData).hasSize(10) - } - - @Test - fun `when getDetailData before load, then empty list`() { - initViewModel() - - val detailData = viewModel.getDetailData() - assertThat(detailData).isEmpty() - } - // endregion - - // region Repository interaction - @Test - fun `when loadData, then init and fetchTags called`() = - test { - whenever( - statsRepository.fetchTags(any(), any()) - ).thenReturn(createSuccessResult()) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - verify(statsRepository) - .init(TEST_ACCESS_TOKEN) - verify(statsRepository) - .fetchTags(eq(TEST_SITE_ID), any()) - } - // endregion - private fun createSuccessResult() = TagsResult.Success( StatsTagsData( @@ -685,19 +557,17 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L - private const val TEST_ACCESS_TOKEN = - "test_access_token" private const val NO_SITE_ERROR = "No site selected" private const val API_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = + "An unknown error occurred" private const val TEST_CATEGORY_NAME = "Uncategorized" private const val TEST_CATEGORY_VIEWS = 83L private const val TEST_TAG_NAME = "snaps" private const val TEST_TAG_VIEWS = 15L - - private const val CARD_MAX_ITEMS = 7 } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4a7e4070f6c..1eda59b10a98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-502e9561f2a68294f0065867bab9214cc9a6b78c' +wordpress-rs = 'trunk-262a778ead5f163f3450d62adfac21fb32048714' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3'