1 /*
2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.material
18 
19 import android.os.Build
20 import androidx.compose.foundation.interaction.Interaction
21 import androidx.compose.foundation.interaction.MutableInteractionSource
22 import androidx.compose.foundation.interaction.PressInteraction
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.material.icons.Icons
25 import androidx.compose.material.icons.filled.Favorite
26 import androidx.compose.runtime.Composable
27 import androidx.compose.runtime.rememberCoroutineScope
28 import androidx.compose.testutils.assertAgainstGolden
29 import androidx.compose.ui.Modifier
30 import androidx.compose.ui.geometry.Offset
31 import androidx.compose.ui.graphics.Color
32 import androidx.compose.ui.platform.testTag
33 import androidx.compose.ui.semantics.semantics
34 import androidx.compose.ui.test.captureToImage
35 import androidx.compose.ui.test.junit4.createComposeRule
36 import androidx.compose.ui.test.onNodeWithTag
37 import androidx.test.ext.junit.runners.AndroidJUnit4
38 import androidx.test.filters.LargeTest
39 import androidx.test.filters.SdkSuppress
40 import androidx.test.screenshot.AndroidXScreenshotTestRule
41 import kotlinx.coroutines.CoroutineScope
42 import kotlinx.coroutines.launch
43 import org.junit.Ignore
44 import org.junit.Rule
45 import org.junit.Test
46 import org.junit.runner.RunWith
47 
48 @LargeTest
49 @RunWith(AndroidJUnit4::class)
50 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
51 @OptIn(ExperimentalMaterialApi::class)
52 class BottomNavigationScreenshotTest {
53 
54     @get:Rule val composeTestRule = createComposeRule()
55 
56     @get:Rule val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_MATERIAL)
57 
58     @Test
lightTheme_defaultColorsnull59     fun lightTheme_defaultColors() {
60         val interactionSource = MutableInteractionSource()
61 
62         var scope: CoroutineScope? = null
63 
64         composeTestRule.setContent {
65             MaterialTheme(lightColors()) {
66                 scope = rememberCoroutineScope()
67                 DefaultBottomNavigation(interactionSource)
68             }
69         }
70 
71         assertBottomNavigationMatches(
72             scope = scope!!,
73             interactionSource = interactionSource,
74             interaction = null,
75             goldenIdentifier = "bottomNavigation_lightTheme_defaultColors"
76         )
77     }
78 
79     @Test
80     @Ignore("b/355413615")
lightTheme_defaultColors_pressednull81     fun lightTheme_defaultColors_pressed() {
82         val interactionSource = MutableInteractionSource()
83 
84         var scope: CoroutineScope? = null
85 
86         composeTestRule.setContent {
87             MaterialTheme(lightColors()) {
88                 scope = rememberCoroutineScope()
89                 DefaultBottomNavigation(interactionSource)
90             }
91         }
92 
93         assertBottomNavigationMatches(
94             scope = scope!!,
95             interactionSource = interactionSource,
96             interaction = PressInteraction.Press(Offset(10f, 10f)),
97             goldenIdentifier = "bottomNavigation_lightTheme_defaultColors_pressed"
98         )
99     }
100 
101     @Test
lightTheme_surfaceColorsnull102     fun lightTheme_surfaceColors() {
103         val interactionSource = MutableInteractionSource()
104 
105         var scope: CoroutineScope? = null
106 
107         composeTestRule.setContent {
108             MaterialTheme(lightColors()) {
109                 scope = rememberCoroutineScope()
110                 CustomBottomNavigation(
111                     interactionSource,
112                     backgroundColor = MaterialTheme.colors.surface,
113                     selectedContentColor = MaterialTheme.colors.primary,
114                     unselectedContentColor = MaterialTheme.colors.onSurface
115                 )
116             }
117         }
118 
119         assertBottomNavigationMatches(
120             scope = scope!!,
121             interactionSource = interactionSource,
122             interaction = null,
123             goldenIdentifier = "bottomNavigation_lightTheme_surfaceColors"
124         )
125     }
126 
127     @Test
128     @Ignore("b/355413615")
lightTheme_surfaceColors_pressednull129     fun lightTheme_surfaceColors_pressed() {
130         val interactionSource = MutableInteractionSource()
131 
132         var scope: CoroutineScope? = null
133 
134         composeTestRule.setContent {
135             MaterialTheme(lightColors()) {
136                 scope = rememberCoroutineScope()
137                 CustomBottomNavigation(
138                     interactionSource,
139                     backgroundColor = MaterialTheme.colors.surface,
140                     selectedContentColor = MaterialTheme.colors.primary,
141                     unselectedContentColor = MaterialTheme.colors.onSurface
142                 )
143             }
144         }
145 
146         assertBottomNavigationMatches(
147             scope = scope!!,
148             interactionSource = interactionSource,
149             interaction = PressInteraction.Press(Offset(10f, 10f)),
150             goldenIdentifier = "bottomNavigation_lightTheme_surfaceColors_pressed"
151         )
152     }
153 
154     @Test
darkTheme_defaultColorsnull155     fun darkTheme_defaultColors() {
156         val interactionSource = MutableInteractionSource()
157 
158         var scope: CoroutineScope? = null
159 
160         composeTestRule.setContent {
161             MaterialTheme(darkColors()) {
162                 scope = rememberCoroutineScope()
163                 DefaultBottomNavigation(interactionSource)
164             }
165         }
166 
167         assertBottomNavigationMatches(
168             scope = scope!!,
169             interactionSource = interactionSource,
170             interaction = null,
171             goldenIdentifier = "bottomNavigation_darkTheme_defaultColors"
172         )
173     }
174 
175     @Test
176     @Ignore("b/355413615")
darkTheme_defaultColors_pressednull177     fun darkTheme_defaultColors_pressed() {
178         val interactionSource = MutableInteractionSource()
179 
180         var scope: CoroutineScope? = null
181 
182         composeTestRule.setContent {
183             MaterialTheme(darkColors()) {
184                 scope = rememberCoroutineScope()
185                 DefaultBottomNavigation(interactionSource)
186             }
187         }
188 
189         assertBottomNavigationMatches(
190             scope = scope!!,
191             interactionSource = interactionSource,
192             interaction = PressInteraction.Press(Offset(10f, 10f)),
193             goldenIdentifier = "bottomNavigation_darkTheme_defaultColors_pressed"
194         )
195     }
196 
197     // Dark theme by default uses `surface` as the background color, but the selectedContentColor
198     // defaults to `onSurface`, whereas a typical use case is for it to be `primary`. This test
199     // matches that use case.
200     @Test
darkTheme_surfaceColorsnull201     fun darkTheme_surfaceColors() {
202         val interactionSource = MutableInteractionSource()
203 
204         var scope: CoroutineScope? = null
205 
206         composeTestRule.setContent {
207             MaterialTheme(darkColors()) {
208                 scope = rememberCoroutineScope()
209                 CustomBottomNavigation(
210                     interactionSource,
211                     backgroundColor = MaterialTheme.colors.surface,
212                     selectedContentColor = MaterialTheme.colors.primary,
213                     unselectedContentColor = MaterialTheme.colors.onSurface
214                 )
215             }
216         }
217 
218         assertBottomNavigationMatches(
219             scope = scope!!,
220             interactionSource = interactionSource,
221             interaction = null,
222             goldenIdentifier = "bottomNavigation_darkTheme_surfaceColors"
223         )
224     }
225 
226     @Test
227     @Ignore("b/355413615")
darkTheme_surfaceColors_pressednull228     fun darkTheme_surfaceColors_pressed() {
229         val interactionSource = MutableInteractionSource()
230 
231         var scope: CoroutineScope? = null
232 
233         composeTestRule.setContent {
234             MaterialTheme(darkColors()) {
235                 scope = rememberCoroutineScope()
236                 CustomBottomNavigation(
237                     interactionSource,
238                     backgroundColor = MaterialTheme.colors.surface,
239                     selectedContentColor = MaterialTheme.colors.primary,
240                     unselectedContentColor = MaterialTheme.colors.onSurface
241                 )
242             }
243         }
244 
245         assertBottomNavigationMatches(
246             scope = scope!!,
247             interactionSource = interactionSource,
248             interaction = PressInteraction.Press(Offset(10f, 10f)),
249             goldenIdentifier = "bottomNavigation_darkTheme_surfaceColors_pressed"
250         )
251     }
252 
253     @Test
darkTheme_primaryColorsnull254     fun darkTheme_primaryColors() {
255         val interactionSource = MutableInteractionSource()
256 
257         var scope: CoroutineScope? = null
258 
259         composeTestRule.setContent {
260             MaterialTheme(darkColors()) {
261                 scope = rememberCoroutineScope()
262                 CustomBottomNavigation(
263                     interactionSource,
264                     backgroundColor = MaterialTheme.colors.primary,
265                     selectedContentColor = MaterialTheme.colors.onPrimary,
266                     unselectedContentColor = MaterialTheme.colors.onPrimary
267                 )
268             }
269         }
270 
271         assertBottomNavigationMatches(
272             scope = scope!!,
273             interactionSource = interactionSource,
274             interaction = null,
275             goldenIdentifier = "bottomNavigation_darkTheme_primaryColors"
276         )
277     }
278 
279     @Test
280     @Ignore("b/355413615")
darkTheme_primaryColors_pressednull281     fun darkTheme_primaryColors_pressed() {
282         val interactionSource = MutableInteractionSource()
283 
284         var scope: CoroutineScope? = null
285 
286         composeTestRule.setContent {
287             MaterialTheme(darkColors()) {
288                 scope = rememberCoroutineScope()
289                 CustomBottomNavigation(
290                     interactionSource,
291                     backgroundColor = MaterialTheme.colors.primary,
292                     selectedContentColor = MaterialTheme.colors.onPrimary,
293                     unselectedContentColor = MaterialTheme.colors.onPrimary
294                 )
295             }
296         }
297 
298         assertBottomNavigationMatches(
299             scope = scope!!,
300             interactionSource = interactionSource,
301             interaction = PressInteraction.Press(Offset(10f, 10f)),
302             goldenIdentifier = "bottomNavigation_darkTheme_primaryColors_pressed"
303         )
304     }
305 
306     /**
307      * Asserts that the BottomNavigation matches the screenshot with identifier [goldenIdentifier].
308      *
309      * @param scope [CoroutineScope] used to interact with [MutableInteractionSource]
310      * @param interactionSource the [MutableInteractionSource] used for the first
311      *   BottomNavigationItem
312      * @param interaction the [Interaction] to assert for, or `null` if no [Interaction].
313      * @param goldenIdentifier the identifier for the corresponding screenshot
314      */
assertBottomNavigationMatchesnull315     private fun assertBottomNavigationMatches(
316         scope: CoroutineScope,
317         interactionSource: MutableInteractionSource,
318         interaction: Interaction? = null,
319         goldenIdentifier: String
320     ) {
321         if (interaction != null) {
322             composeTestRule.runOnIdle {
323                 // Start ripple
324                 scope.launch { interactionSource.emit(interaction) }
325             }
326 
327             composeTestRule.waitForIdle()
328             // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't
329             // properly wait for synchronization. Instead just wait until after the ripples are
330             // finished animating.
331             Thread.sleep(300)
332         }
333 
334         // Capture and compare screenshots
335         composeTestRule
336             .onNodeWithTag(Tag)
337             .captureToImage()
338             .assertAgainstGolden(screenshotRule, goldenIdentifier)
339     }
340 }
341 
342 /**
343  * Default colored [BottomNavigation] with three [BottomNavigationItem]s. The first
344  * [BottomNavigationItem] is selected, and the rest are not.
345  *
346  * @param interactionSource the [MutableInteractionSource] for the first [BottomNavigationItem], to
347  *   control its visual state.
348  */
349 @Composable
DefaultBottomNavigationnull350 private fun DefaultBottomNavigation(interactionSource: MutableInteractionSource) {
351     Box(Modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
352         BottomNavigation {
353             BottomNavigationItem(
354                 icon = { Icon(Icons.Filled.Favorite, null) },
355                 selected = true,
356                 onClick = {},
357                 interactionSource = interactionSource
358             )
359             BottomNavigationItem(
360                 icon = { Icon(Icons.Filled.Favorite, null) },
361                 selected = false,
362                 onClick = {}
363             )
364             BottomNavigationItem(
365                 icon = { Icon(Icons.Filled.Favorite, null) },
366                 selected = false,
367                 onClick = {}
368             )
369         }
370     }
371 }
372 
373 /**
374  * Custom colored [BottomNavigation] with three [BottomNavigationItem]s. The first
375  * [BottomNavigationItem] is selected, and the rest are not.
376  *
377  * @param interactionSource the [MutableInteractionSource] for the first [BottomNavigationItem], to
378  *   control its visual state.
379  * @param backgroundColor the backgroundColor of the [BottomNavigation]
380  * @param selectedContentColor the content color for a selected [BottomNavigationItem] (first item)
381  * @param unselectedContentColor the content color for an unselected [BottomNavigationItem] (second
382  *   and third items)
383  */
384 @Composable
CustomBottomNavigationnull385 private fun CustomBottomNavigation(
386     interactionSource: MutableInteractionSource,
387     backgroundColor: Color,
388     selectedContentColor: Color,
389     unselectedContentColor: Color
390 ) {
391     // Apply default emphasis
392     @Suppress("NAME_SHADOWING")
393     val unselectedContentColor = unselectedContentColor.copy(alpha = ContentAlpha.medium)
394     Box(Modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
395         BottomNavigation(backgroundColor = backgroundColor) {
396             BottomNavigationItem(
397                 icon = { Icon(Icons.Filled.Favorite, null) },
398                 selected = true,
399                 onClick = {},
400                 interactionSource = interactionSource,
401                 selectedContentColor = selectedContentColor,
402                 unselectedContentColor = unselectedContentColor
403             )
404             BottomNavigationItem(
405                 icon = { Icon(Icons.Filled.Favorite, null) },
406                 selected = false,
407                 onClick = {},
408                 selectedContentColor = selectedContentColor,
409                 unselectedContentColor = unselectedContentColor
410             )
411             BottomNavigationItem(
412                 icon = { Icon(Icons.Filled.Favorite, null) },
413                 selected = false,
414                 onClick = {},
415                 selectedContentColor = selectedContentColor,
416                 unselectedContentColor = unselectedContentColor
417             )
418         }
419     }
420 }
421 
422 private const val Tag = "BottomNavigation"
423