commit
53248e6dac
@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.ui.following
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.google.samples.apps.nowinandroid.R
|
||||
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* UI test for checking the correct behaviour of the Following screen;
|
||||
* Verifies that, when a specific UiState is set, the corresponding
|
||||
* composables and details are shown
|
||||
*/
|
||||
class FollowingScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private lateinit var followingLoading: String
|
||||
private lateinit var followingErrorHeader: String
|
||||
private lateinit var followingTopicCardIcon: String
|
||||
private lateinit var followingTopicCardFollowButton: String
|
||||
private lateinit var followingTopicCardUnfollowButton: String
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
composeTestRule.activity.apply {
|
||||
followingLoading = getString(R.string.following_loading)
|
||||
followingErrorHeader = getString(R.string.following_error_header)
|
||||
followingTopicCardIcon = getString(R.string.following_topic_card_icon_content_desc)
|
||||
followingTopicCardFollowButton =
|
||||
getString(R.string.following_topic_card_follow_button_content_desc)
|
||||
followingTopicCardUnfollowButton =
|
||||
getString(R.string.following_topic_card_unfollow_button_content_desc)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun niaLoadingIndicator_whenScreenIsLoading_showLoading() {
|
||||
composeTestRule.setContent {
|
||||
FollowingScreen(
|
||||
uiState = FollowingUiState.Loading,
|
||||
followTopic = { _, _ -> },
|
||||
navigateToTopic = {}
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(followingLoading)
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followingWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
|
||||
composeTestRule.setContent {
|
||||
FollowingScreen(
|
||||
uiState = FollowingUiState.Topics(topics = testTopics),
|
||||
followTopic = { _, _ -> },
|
||||
navigateToTopic = {}
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(TOPIC_1_NAME)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(TOPIC_2_NAME)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(TOPIC_3_NAME)
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(TOPIC_DESC)
|
||||
.assertCountEquals(testTopics.count())
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithContentDescription(followingTopicCardIcon)
|
||||
.assertCountEquals(testTopics.count())
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithContentDescription(followingTopicCardFollowButton)
|
||||
.assertCountEquals(numberOfUnfollowedTopics)
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithContentDescription(followingTopicCardUnfollowButton)
|
||||
.assertCountEquals(testTopics.filter { it.followed }.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followingError_whenErrorOccurs_thenShowEmptyErrorScreen() {
|
||||
composeTestRule.setContent {
|
||||
FollowingScreen(
|
||||
uiState = FollowingUiState.Error,
|
||||
followTopic = { _, _ -> },
|
||||
navigateToTopic = {}
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(followingErrorHeader)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOPIC_1_NAME = "Headlines"
|
||||
private const val TOPIC_2_NAME = "UI"
|
||||
private const val TOPIC_3_NAME = "Tools"
|
||||
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
|
||||
|
||||
private val testTopics = listOf(
|
||||
Topic(
|
||||
id = 0,
|
||||
name = TOPIC_1_NAME,
|
||||
description = TOPIC_DESC,
|
||||
followed = true
|
||||
),
|
||||
Topic(
|
||||
id = 1,
|
||||
name = TOPIC_2_NAME,
|
||||
description = TOPIC_DESC
|
||||
),
|
||||
Topic(
|
||||
id = 2,
|
||||
name = TOPIC_3_NAME,
|
||||
description = TOPIC_DESC
|
||||
)
|
||||
)
|
||||
|
||||
private val numberOfUnfollowedTopics = testTopics.filter { !it.followed }.size
|
@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.ripple.RippleAlpha
|
||||
import androidx.compose.material.ripple.RippleTheme
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NiaToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes titleRes: Int,
|
||||
onSearchClick: () -> Unit = {},
|
||||
onMenuClick: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
IconButton(onClick = { onSearchClick() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = titleRes),
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
IconButton(onClick = { onMenuClick() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NiaLoadingIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
contentDesc: String
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.wrapContentSize(Alignment.Center)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.semantics { contentDescription = contentDesc },
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object ClearRippleTheme : RippleTheme {
|
||||
|
||||
@Composable
|
||||
override fun defaultColor(): Color = Color.Transparent
|
||||
|
||||
@Composable
|
||||
override fun rippleAlpha() = RippleAlpha(
|
||||
draggedAlpha = 0.0f,
|
||||
focusedAlpha = 0.0f,
|
||||
hoveredAlpha = 0.0f,
|
||||
pressedAlpha = 0.0f,
|
||||
)
|
||||
}
|
@ -0,0 +1,258 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.ui.following
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconToggleButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Android
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.samples.apps.nowinandroid.R
|
||||
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||
import com.google.samples.apps.nowinandroid.ui.NiaLoadingIndicator
|
||||
import com.google.samples.apps.nowinandroid.ui.NiaToolbar
|
||||
import com.google.samples.apps.nowinandroid.ui.theme.NiaTheme
|
||||
|
||||
@Composable
|
||||
fun FollowingRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
navigateToTopic: () -> Unit,
|
||||
viewModel: FollowingViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
FollowingScreen(
|
||||
modifier = modifier,
|
||||
uiState = uiState,
|
||||
followTopic = viewModel::followTopic,
|
||||
navigateToTopic = navigateToTopic
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowingScreen(
|
||||
uiState: FollowingUiState,
|
||||
followTopic: (Int, Boolean) -> Unit,
|
||||
navigateToTopic: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
NiaToolbar(titleRes = R.string.following)
|
||||
when (uiState) {
|
||||
FollowingUiState.Loading ->
|
||||
NiaLoadingIndicator(
|
||||
modifier = modifier,
|
||||
contentDesc = stringResource(id = R.string.following_loading),
|
||||
)
|
||||
is FollowingUiState.Topics ->
|
||||
FollowingWithTopicsScreen(
|
||||
uiState = uiState,
|
||||
onTopicClick = { navigateToTopic() },
|
||||
onFollowButtonClick = followTopic,
|
||||
)
|
||||
is FollowingUiState.Error -> FollowingErrorScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowingWithTopicsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
uiState: FollowingUiState.Topics,
|
||||
onTopicClick: () -> Unit,
|
||||
onFollowButtonClick: (Int, Boolean) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
) {
|
||||
uiState.topics.forEach {
|
||||
item {
|
||||
FollowingTopicCard(
|
||||
topic = it,
|
||||
onTopicClick = onTopicClick,
|
||||
onFollowButtonClick = onFollowButtonClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowingErrorScreen() {
|
||||
Text(text = stringResource(id = R.string.following_error_header))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowingTopicCard(
|
||||
topic: Topic,
|
||||
onTopicClick: () -> Unit,
|
||||
onFollowButtonClick: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
start = 24.dp,
|
||||
end = 8.dp,
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
TopicIcon(
|
||||
modifier = Modifier.padding(end = 24.dp),
|
||||
onClick = onTopicClick
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.wrapContentSize(Alignment.Center)
|
||||
.weight(1f)
|
||||
.clickable { onTopicClick() }
|
||||
) {
|
||||
TopicTitle(topicName = topic.name)
|
||||
TopicDescription(topicDescription = topic.description)
|
||||
}
|
||||
FollowButton(
|
||||
topicId = topic.id,
|
||||
onClick = onFollowButtonClick,
|
||||
isFollowed = topic.followed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopicTitle(
|
||||
topicName: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = topicName,
|
||||
style = MaterialTheme.typography.h5,
|
||||
modifier = modifier.padding(top = 12.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopicDescription(topicDescription: String) {
|
||||
Text(
|
||||
text = topicDescription,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.wrapContentSize(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopicIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Android,
|
||||
tint = Color.Magenta,
|
||||
contentDescription = stringResource(id = R.string.following_topic_card_icon_content_desc),
|
||||
modifier = modifier
|
||||
.size(64.dp)
|
||||
.clickable { onClick() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowButton(
|
||||
topicId: Int,
|
||||
isFollowed: Boolean,
|
||||
onClick: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
IconToggleButton(
|
||||
checked = isFollowed,
|
||||
onCheckedChange = { onClick(topicId, !isFollowed) }
|
||||
) {
|
||||
if (isFollowed) {
|
||||
FollowedTopicIcon()
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription =
|
||||
stringResource(id = R.string.following_topic_card_follow_button_content_desc),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowedTopicIcon() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.background(
|
||||
color = Color.Magenta.copy(alpha = 0.5f),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Done,
|
||||
contentDescription =
|
||||
stringResource(id = R.string.following_topic_card_unfollow_button_content_desc),
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview("Topic card")
|
||||
@Composable
|
||||
fun TopicCardPreview() {
|
||||
NiaTheme {
|
||||
Surface {
|
||||
FollowingTopicCard(
|
||||
Topic(
|
||||
id = 0,
|
||||
name = "Compose",
|
||||
description = "Description"
|
||||
),
|
||||
onTopicClick = {},
|
||||
onFollowButtonClick = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.ui.following
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||
import com.google.samples.apps.nowinandroid.data.repository.TopicsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class FollowingViewModel @Inject constructor(
|
||||
private val topicsRepository: TopicsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val followedTopicIdsStream = topicsRepository.getFollowedTopicIdsStream()
|
||||
.catch { FollowingState.Error }
|
||||
.map { followedTopics ->
|
||||
FollowingState.Topics(topics = followedTopics)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<FollowingUiState> = combine(
|
||||
followedTopicIdsStream,
|
||||
topicsRepository.getTopicsStream(),
|
||||
) { followedTopicIdsState, topics ->
|
||||
if (followedTopicIdsState is FollowingState.Topics) {
|
||||
mapFollowedAndUnfollowedTopics(topics)
|
||||
} else {
|
||||
flowOf(FollowingUiState.Error)
|
||||
}
|
||||
}
|
||||
.flatMapLatest { it }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = FollowingUiState.Loading
|
||||
)
|
||||
|
||||
fun followTopic(followedTopicId: Int, followed: Boolean) {
|
||||
viewModelScope.launch {
|
||||
topicsRepository.toggleFollowedTopicId(followedTopicId, followed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapFollowedAndUnfollowedTopics(topics: List<Topic>): Flow<FollowingUiState.Topics> =
|
||||
topicsRepository.getFollowedTopicIdsStream().map { followedTopicIds ->
|
||||
FollowingUiState.Topics(
|
||||
topics =
|
||||
topics.map {
|
||||
Topic(
|
||||
it.id,
|
||||
it.name,
|
||||
it.description,
|
||||
followedTopicIds.contains(it.id)
|
||||
)
|
||||
}.sortedBy { it.name }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface FollowingState {
|
||||
data class Topics(val topics: Set<Int>) : FollowingState
|
||||
object Error : FollowingState
|
||||
}
|
||||
|
||||
sealed interface FollowingUiState {
|
||||
object Loading : FollowingUiState
|
||||
data class Topics(val topics: List<Topic>) : FollowingUiState
|
||||
object Error : FollowingUiState
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.ui.following
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.samples.apps.nowinandroid.data.model.Topic
|
||||
import com.google.samples.apps.nowinandroid.testutil.TestDispatcherRule
|
||||
import com.google.samples.apps.nowinandroid.testutil.TestTopicsRepository
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class FollowingViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = TestDispatcherRule()
|
||||
|
||||
private val topicsRepository = TestTopicsRepository()
|
||||
private lateinit var viewModel: FollowingViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
viewModel = FollowingViewModel(topicsRepository = topicsRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiState_whenInitialized_thenShowLoading() = runTest {
|
||||
viewModel.uiState.test {
|
||||
assertEquals(FollowingUiState.Loading, awaitItem())
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
|
||||
viewModel.uiState.test {
|
||||
assertEquals(FollowingUiState.Loading, awaitItem())
|
||||
topicsRepository.setFollowedTopicIds(emptySet())
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
|
||||
viewModel.uiState
|
||||
.test {
|
||||
awaitItem()
|
||||
topicsRepository.sendTopics(testInputTopics)
|
||||
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].id))
|
||||
|
||||
awaitItem()
|
||||
viewModel.followTopic(
|
||||
followedTopicId = testInputTopics[1].id,
|
||||
followed = !testInputTopics[1].followed
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
FollowingUiState.Topics(topics = testOutputTopics),
|
||||
awaitItem()
|
||||
)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
|
||||
viewModel.uiState
|
||||
.test {
|
||||
awaitItem()
|
||||
topicsRepository.sendTopics(testOutputTopics)
|
||||
topicsRepository.setFollowedTopicIds(
|
||||
setOf(testOutputTopics[0].id, testOutputTopics[1].id)
|
||||
)
|
||||
|
||||
awaitItem()
|
||||
viewModel.followTopic(
|
||||
followedTopicId = testOutputTopics[1].id,
|
||||
followed = !testOutputTopics[1].followed
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
FollowingUiState.Topics(topics = testInputTopics),
|
||||
awaitItem()
|
||||
)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOPIC_1_NAME = "Android Studio"
|
||||
private const val TOPIC_2_NAME = "Build"
|
||||
private const val TOPIC_3_NAME = "Compose"
|
||||
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
|
||||
|
||||
private val testInputTopics = listOf(
|
||||
Topic(
|
||||
id = 0,
|
||||
name = TOPIC_1_NAME,
|
||||
description = TOPIC_DESC,
|
||||
followed = true
|
||||
),
|
||||
Topic(
|
||||
id = 1,
|
||||
name = TOPIC_2_NAME,
|
||||
description = TOPIC_DESC
|
||||
),
|
||||
Topic(
|
||||
id = 2,
|
||||
name = TOPIC_3_NAME,
|
||||
description = TOPIC_DESC
|
||||
)
|
||||
)
|
||||
|
||||
private val testOutputTopics = listOf(
|
||||
Topic(
|
||||
id = 0,
|
||||
name = TOPIC_1_NAME,
|
||||
description = TOPIC_DESC,
|
||||
followed = true
|
||||
),
|
||||
Topic(
|
||||
id = 1,
|
||||
name = TOPIC_2_NAME,
|
||||
description = TOPIC_DESC,
|
||||
followed = true
|
||||
),
|
||||
Topic(
|
||||
id = 2,
|
||||
name = TOPIC_3_NAME,
|
||||
description = TOPIC_DESC
|
||||
)
|
||||
)
|
Loading…
Reference in new issue