Move new UseCase and tests into separate files

Change-Id: I4c337473ca0a60a5fccbb8aa640735cf7d616e71
pull/552/head
Don Turner 2 years ago
parent e86cdea381
commit e536c396f9

@ -24,11 +24,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -55,56 +51,6 @@ class GetUserNewsResourcesUseCase @Inject constructor(
}.mapToUserNewsResources(userDataRepository.userData) }.mapToUserNewsResources(userDataRepository.userData)
} }
class GetFollowedUserNewsResourcesUseCase @Inject constructor(
private val userDataRepository: UserDataRepository,
val getUserNewsResources: GetUserNewsResourcesUseCase,
) {
/**
* Returns a list of UserNewsResources for topics which the user is following
*/
operator fun invoke(): Flow<List<UserNewsResource>> =
/**
* This sequence of flow transformation functions does the following:
*
* - map: maps the user data into a set of followed topic IDs or null if we should return
* an empty list
* - distinctUntilChanged: will only emit a set of followed topic IDs if it's changed. This
* avoids calling potentially expensive operations (like setting up a new flow) when nothing
* has changed.
* - flatMapLatest: getUserNewsResources returns a flow, so we have a flow inside a
* flow. flatMapLatest moves the inner flow (the one we want to return) to the outer flow
* and cancels any previous flows created by getUserNewsResources.
*/
userDataRepository.userData
.map { userData ->
if (shouldShowEmptyFeed(userData)) {
null
} else {
userData.followedTopics
}
}
.distinctUntilChanged()
.flatMapLatest { followedTopics ->
if (followedTopics == null) {
flowOf(emptyList())
} else {
getUserNewsResources(filterTopicIds = followedTopics)
}
}
/**
* If the user hasn't completed the onboarding and hasn't selected any interests
* show an empty news list to clearly demonstrate that their selections affect the
* news articles they will see.
*
* Note: It should not be possible for the user to get into a state where the onboarding
* is not displayed AND they haven't followed any topics, however, this method is to safeguard
* against that scenario in future.
*/
private fun shouldShowEmptyFeed(userData: UserData) =
!userData.shouldHideOnboarding && userData.followedTopics.isEmpty()
}
private fun Flow<List<NewsResource>>.mapToUserNewsResources( private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData>, userDataStream: Flow<UserData>,
): Flow<List<UserNewsResource>> = ): Flow<List<UserNewsResource>> =

@ -83,55 +83,6 @@ class GetUserNewsResourcesUseCaseTest {
} }
} }
class GetFollowedUserNewsResourcesUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository()
private val getUserNewsResourcesUseCase =
GetUserNewsResourcesUseCase(newsRepository, userDataRepository)
val useCase =
GetFollowedUserNewsResourcesUseCase(userDataRepository, getUserNewsResourcesUseCase)
@Test
fun whenOnboardingShownAndNoTopicsFollowed_emptyListIsReturned() = runTest {
val followedNewsResources = useCase()
// Send some news resources and empty user data
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData)
// Check that an empty list is returned
assertEquals(
emptyList(),
followedNewsResources.first(),
)
}
@Test
fun whenTopicsAreFollowed_correctNewsResourcesAreReturned() = runTest {
val followedNewsResources = useCase()
// Send some news resources and user data with a followed topic
newsRepository.sendNewsResources(sampleNewsResources)
val userData = emptyUserData.copy(
followedTopics = setOf(sampleTopic1.id),
)
userDataRepository.setUserData(userData)
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.mapToUserNewsResources(userData),
followedNewsResources.first(),
)
}
}
private val sampleTopic1 = Topic( private val sampleTopic1 = Topic(
id = "Topic1", id = "Topic1",
name = "Headlines", name = "Headlines",

@ -21,13 +21,18 @@ import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetFollowedUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -37,7 +42,7 @@ import javax.inject.Inject
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor, syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
getFollowedUserNewsResources: GetFollowedUserNewsResourcesUseCase, private val getUserNewsResources: GetUserNewsResourcesUseCase,
getFollowableTopics: GetFollowableTopicsUseCase, getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() { ) : ViewModel() {
@ -51,10 +56,8 @@ class ForYouViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> = getFollowedUserNewsResources()
getFollowedUserNewsResources().map { .map(NewsFeedUiState::Success)
NewsFeedUiState.Success(it)
}
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
@ -95,4 +98,66 @@ class ForYouViewModel @Inject constructor(
userDataRepository.setShouldHideOnboarding(true) userDataRepository.setShouldHideOnboarding(true)
} }
} }
/**
* This sequence of flow transformation functions does the following:
*
* - map: maps the user data into a set of followed topic IDs or null if we should return
* an empty list
* - distinctUntilChanged: will only emit a set of followed topic IDs if it's changed. This
* avoids calling potentially expensive operations (like setting up a new flow) when nothing
* has changed.
* - flatMapLatest: getUserNewsResources returns a flow, so we have a flow inside a
* flow. flatMapLatest moves the inner flow (the one we want to return) to the outer flow
* and cancels any previous flows created by getUserNewsResources.
*/
private fun getFollowedUserNewsResources(): Flow<List<UserNewsResource>> =
userDataRepository.userData
.map { userData ->
if (userData.shouldShowEmptyFeed()) {
null
} else {
userData.followedTopics
}
}
.distinctUntilChanged()
.flatMapLatest { followedTopics ->
if (followedTopics == null) {
flowOf(emptyList())
} else {
getUserNewsResources(filterTopicIds = followedTopics)
}
}
} }
// Alternative approach (not currently being called)
private fun Flow<UserData>.getFollowedUserNewsResources(
getUserNewsResources: GetUserNewsResourcesUseCase,
): Flow<List<UserNewsResource>> =
map { userData ->
if (userData.shouldShowEmptyFeed()) {
null
} else {
userData.followedTopics
}
}
.distinctUntilChanged()
.flatMapLatest { followedTopics ->
if (followedTopics == null) {
flowOf(emptyList())
} else {
getUserNewsResources(filterTopicIds = followedTopics)
}
}
/**
* If the user hasn't completed the onboarding and hasn't selected any interests
* show an empty news list to clearly demonstrate that their selections affect the
* news articles they will see.
*
* Note: It should not be possible for the user to get into a state where the onboarding
* is not displayed AND they haven't followed any topics, however, this method is to safeguard
* against that scenario in future.
*/
private fun UserData.shouldShowEmptyFeed() =
!shouldHideOnboarding && followedTopics.isEmpty()

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetFollowedUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
@ -61,10 +60,6 @@ class ForYouViewModelTest {
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
private val getFollowedUserNewsResourcesUseCase = GetFollowedUserNewsResourcesUseCase(
userDataRepository = userDataRepository,
getUserNewsResources = getUserNewsResourcesUseCase,
)
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
@ -77,7 +72,7 @@ class ForYouViewModelTest {
viewModel = ForYouViewModel( viewModel = ForYouViewModel(
syncStatusMonitor = syncStatusMonitor, syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getFollowedUserNewsResources = getFollowedUserNewsResourcesUseCase, getUserNewsResources = getUserNewsResourcesUseCase,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
) )
} }

Loading…
Cancel
Save