diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 93c674bcc..b15024cc7 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -16,13 +16,11 @@ package com.google.samples.apps.nowinandroid.ui -import androidx.annotation.StringRes import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription @@ -47,7 +45,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import javax.inject.Inject -import kotlin.properties.ReadOnlyProperty import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR @@ -88,9 +85,6 @@ class NavigationTest { @Inject lateinit var topicsRepository: TopicsRepository - private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = - ReadOnlyProperty { _, _ -> activity.getString(resId) } - // The strings used for matching in these tests private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title) diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/UiTestExtensions.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/UiTestExtensions.kt new file mode 100644 index 000000000..bdc09885d --- /dev/null +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/UiTestExtensions.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 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.ui.test.junit4.AndroidComposeTestRule +import kotlin.properties.ReadOnlyProperty + +fun AndroidComposeTestRule<*, *>.stringResource( + @StringRes resId: Int, +): ReadOnlyProperty = + ReadOnlyProperty { _, _ -> activity.getString(resId) } diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreenTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreenTest.kt new file mode 100644 index 000000000..21ac3e920 --- /dev/null +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreenTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2024 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.interests2pane + +import androidx.activity.compose.BackHandler +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.test.espresso.Espresso +import androidx.window.core.layout.WindowSizeClass +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.ui.stringResource +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import javax.inject.Inject +import kotlin.test.assertTrue +import com.google.samples.apps.nowinandroid.feature.topic.R as FeatureTopicR + +@HiltAndroidTest +class InterestsListDetailScreenTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var topicsRepository: TopicsRepository + + // The strings used for matching in these tests. + private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest) + private val listPaneTag = "interests:topics" + + private val Topic.testTag + get() = "topic:${this.id}" + + // Overrides for device sizes. + private enum class TestDeviceConfig(widthDp: Float, heightDp: Float) { + Compact(412f, 915f), + Expanded(1200f, 840f), + ; + + val sizeOverride = DeviceConfigurationOverride.ForcedSize(DpSize(widthDp.dp, heightDp.dp)) + val adaptiveInfo = WindowAdaptiveInfo( + windowSizeClass = WindowSizeClass.compute(widthDp, heightDp), + windowPosture = Posture(), + ) + } + + @Before + fun setup() { + hiltRule.inject() + } + + /** Convenience function for getting all topics during tests, */ + private fun getTopics(): List = runBlocking { + topicsRepository.getTopics().first() + } + + @Test + fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() { + composeTestRule.apply { + setContent { + with(TestDeviceConfig.Expanded) { + DeviceConfigurationOverride(override = sizeOverride) { + NiaTheme { + InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo) + } + } + } + } + + onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsDisplayed() + } + } + + @Test + fun compactWidth_initialState_showsListPane() { + composeTestRule.apply { + setContent { + with(TestDeviceConfig.Compact) { + DeviceConfigurationOverride(override = sizeOverride) { + NiaTheme { + InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo) + } + } + } + } + + onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + } + } + + @Test + fun expandedWidth_topicSelected_updatesDetailPane() { + composeTestRule.apply { + setContent { + with(TestDeviceConfig.Expanded) { + DeviceConfigurationOverride(override = sizeOverride) { + NiaTheme { + InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo) + } + } + } + } + + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + + onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + onNodeWithTag(firstTopic.testTag).assertIsDisplayed() + } + } + + @Test + fun compactWidth_topicSelected_showsTopicDetailPane() { + composeTestRule.apply { + setContent { + with(TestDeviceConfig.Compact) { + DeviceConfigurationOverride(override = sizeOverride) { + NiaTheme { + InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo) + } + } + } + } + + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + + onNodeWithTag(listPaneTag).assertIsNotDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + onNodeWithTag(firstTopic.testTag).assertIsDisplayed() + } + } + + @Test + fun expandedWidth_backPressFromTopicDetail_leavesInterests() { + var unhandledBackPress = false + composeTestRule.apply { + setContent { + with(TestDeviceConfig.Expanded) { + DeviceConfigurationOverride(override = sizeOverride) { + NiaTheme { + // Back press should not be handled by the two pane layout, and thus + // "fall through" to this BackHandler. + BackHandler { + unhandledBackPress = true + } + InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo) + } + } + } + } + + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + + Espresso.pressBack() + + assertTrue(unhandledBackPress) + } + } + + @Test + fun compactWidth_backPressFromTopicDetail_showsListPane() { + composeTestRule.apply { + setContent { + with(TestDeviceConfig.Compact) { + DeviceConfigurationOverride(override = sizeOverride) { + NiaTheme { + InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo) + } + } + } + } + + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + + Espresso.pressBack() + + onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed() + } + } +} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt index ada4e49d1..919cb44f2 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt @@ -18,11 +18,14 @@ package com.google.samples.apps.nowinandroid.ui.interests2pane import androidx.activity.compose.BackHandler import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable @@ -71,11 +74,13 @@ fun NavGraphBuilder.interestsListDetailScreen() { @Composable internal fun InterestsListDetailScreen( viewModel: Interests2PaneViewModel = hiltViewModel(), + windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), ) { val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle() InterestsListDetailScreen( selectedTopicId = selectedTopicId, onTopicClick = viewModel::onTopicClick, + windowAdaptiveInfo = windowAdaptiveInfo, ) } @@ -84,8 +89,10 @@ internal fun InterestsListDetailScreen( internal fun InterestsListDetailScreen( selectedTopicId: String?, onTopicClick: (String) -> Unit, + windowAdaptiveInfo: WindowAdaptiveInfo, ) { val listDetailNavigator = rememberListDetailPaneScaffoldNavigator( + scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo), initialDestinationHistory = listOfNotNull( ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail).takeIf {