1 /*
2  * Copyright 2021 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.material3
18 
19 import android.os.Build
20 import androidx.compose.foundation.border
21 import androidx.compose.foundation.layout.fillMaxWidth
22 import androidx.compose.foundation.layout.width
23 import androidx.compose.foundation.layout.wrapContentHeight
24 import androidx.compose.foundation.lazy.LazyColumn
25 import androidx.compose.material.icons.Icons
26 import androidx.compose.material.icons.filled.Favorite
27 import androidx.compose.runtime.getValue
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.setValue
30 import androidx.compose.testutils.assertContainsColor
31 import androidx.compose.ui.Modifier
32 import androidx.compose.ui.graphics.Color
33 import androidx.compose.ui.layout.onSizeChanged
34 import androidx.compose.ui.platform.LocalContext
35 import androidx.compose.ui.platform.LocalDensity
36 import androidx.compose.ui.platform.testTag
37 import androidx.compose.ui.semantics.semantics
38 import androidx.compose.ui.test.assertIsEqualTo
39 import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
40 import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
41 import androidx.compose.ui.test.captureToImage
42 import androidx.compose.ui.test.getUnclippedBoundsInRoot
43 import androidx.compose.ui.test.isDialog
44 import androidx.compose.ui.test.junit4.createComposeRule
45 import androidx.compose.ui.test.onNodeWithTag
46 import androidx.compose.ui.unit.dp
47 import androidx.compose.ui.unit.width
48 import androidx.test.ext.junit.runners.AndroidJUnit4
49 import androidx.test.filters.LargeTest
50 import androidx.test.filters.SdkSuppress
51 import com.google.common.truth.Truth.assertThat
52 import kotlinx.coroutines.channels.Channel
53 import kotlinx.coroutines.runBlocking
54 import kotlinx.coroutines.withTimeout
55 import org.junit.Rule
56 import org.junit.Test
57 import org.junit.runner.RunWith
58 
59 @LargeTest
60 @RunWith(AndroidJUnit4::class)
61 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
62 class AlertDialogTest {
63 
64     @get:Rule val rule = createComposeRule()
65 
66     @Test
customStyleProperties_shouldApplynull67     fun customStyleProperties_shouldApply() {
68         var buttonContentColor = Color.Unspecified
69         var expectedButtonContentColor = Color.Unspecified
70         var iconContentColor = Color.Unspecified
71         var titleContentColor = Color.Unspecified
72         var textContentColor = Color.Unspecified
73         rule.setContent {
74             AlertDialog(
75                 onDismissRequest = {},
76                 modifier = Modifier.border(10.dp, Color.Blue),
77                 icon = {
78                     Icon(Icons.Filled.Favorite, contentDescription = null)
79                     iconContentColor = LocalContentColor.current
80                 },
81                 title = {
82                     Text(text = "Title")
83                     titleContentColor = LocalContentColor.current
84                 },
85                 text = {
86                     Text("Text")
87                     textContentColor = LocalContentColor.current
88                 },
89                 confirmButton = {
90                     TextButton(onClick = { /* doSomething() */ }) {
91                         Text("Confirm")
92                         buttonContentColor = LocalContentColor.current
93                         // TODO change this back to the TextButtonTokens.LabelColor once the tokens
94                         // are updated
95                         expectedButtonContentColor = MaterialTheme.colorScheme.primary
96                     }
97                 },
98                 containerColor = Color.Yellow,
99                 tonalElevation = 0.dp,
100                 iconContentColor = Color.Green,
101                 titleContentColor = Color.Magenta,
102                 textContentColor = Color.DarkGray
103             )
104         }
105         rule.waitForIdle()
106         // Assert background
107         rule
108             .onNode(isDialog())
109             .captureToImage()
110             .assertContainsColor(Color.Yellow) // Background
111             .assertContainsColor(Color.Blue) // Modifier border
112 
113         // Assert content colors
114         rule.runOnIdle {
115             assertThat(buttonContentColor).isEqualTo(expectedButtonContentColor)
116             assertThat(iconContentColor).isEqualTo(Color.Green)
117             assertThat(titleContentColor).isEqualTo(Color.Magenta)
118             assertThat(textContentColor).isEqualTo(Color.DarkGray)
119         }
120     }
121 
122     /** Ensure that Dialogs don't press up against the edges of the screen. */
123     @Test
alertDialog_doesNotConsumeFullScreenWidthnull124     fun alertDialog_doesNotConsumeFullScreenWidth() {
125         val dialogWidthCh = Channel<Int>(Channel.CONFLATED)
126         var maxDialogWidth = 0
127         var screenWidth by mutableStateOf(0)
128         rule.setContent {
129             val context = LocalContext.current
130             val density = LocalDensity.current
131             val resScreenWidth = context.resources.configuration.screenWidthDp
132             with(density) {
133                 screenWidth = resScreenWidth.dp.roundToPx()
134                 maxDialogWidth = DialogMaxWidth.roundToPx()
135             }
136 
137             AlertDialog(
138                 modifier =
139                     Modifier.onSizeChanged { dialogWidthCh.trySend(it.width) }.fillMaxWidth(),
140                 onDismissRequest = {},
141                 title = { Text(text = "Title") },
142                 text = {
143                     Text(
144                         "This area typically contains the supportive text " +
145                             "which presents the details regarding the Dialog's purpose."
146                     )
147                 },
148                 confirmButton = {
149                     TextButton(onClick = { /* doSomething() */ }) { Text("Confirm") }
150                 },
151                 dismissButton = {
152                     TextButton(onClick = { /* doSomething() */ }) { Text("Dismiss") }
153                 },
154             )
155         }
156 
157         runBlocking {
158             val dialogWidth = withTimeout(5_000) { dialogWidthCh.receive() }
159             assertThat(dialogWidth).isLessThan(maxDialogWidth)
160             assertThat(dialogWidth).isLessThan(screenWidth)
161         }
162     }
163 
164     /** Ensure that a dialog with custom content don't press up against the edges of the screen. */
165     @OptIn(ExperimentalMaterial3Api::class)
166     @Test
basicAlertDialog_customContentDoesNotConsumeFullScreenWidthnull167     fun basicAlertDialog_customContentDoesNotConsumeFullScreenWidth() {
168         val dialogWidthCh = Channel<Int>(Channel.CONFLATED)
169         var maxDialogWidth = 0
170         var screenWidth by mutableStateOf(0)
171         rule.setContent {
172             val context = LocalContext.current
173             val density = LocalDensity.current
174             val resScreenWidth = context.resources.configuration.screenWidthDp
175             with(density) {
176                 screenWidth = resScreenWidth.dp.roundToPx()
177                 maxDialogWidth = DialogMaxWidth.roundToPx()
178             }
179 
180             BasicAlertDialog(onDismissRequest = {}) {
181                 Surface(
182                     modifier =
183                         Modifier.onSizeChanged { dialogWidthCh.trySend(it.width) }
184                             .wrapContentHeight()
185                             .fillMaxWidth()
186                 ) {
187                     Text(
188                         text =
189                             "This area typically contains the supportive text " +
190                                 "which presents the details regarding the Dialog's purpose.",
191                     )
192                 }
193             }
194         }
195         runBlocking {
196             val dialogWidth = withTimeout(5_000) { dialogWidthCh.receive() }
197             assertThat(dialogWidth).isLessThan(maxDialogWidth)
198             assertThat(dialogWidth).isLessThan(screenWidth)
199         }
200     }
201 
202     /** Ensure the Dialog's min width. */
203     @Test
alertDialog_minWidthnull204     fun alertDialog_minWidth() {
205         val dialogWidthCh = Channel<Int>(Channel.CONFLATED)
206         var minDialogWidth = 0
207         rule.setContent {
208             with(LocalDensity.current) { minDialogWidth = DialogMinWidth.roundToPx() }
209             AlertDialog(
210                 modifier = Modifier.onSizeChanged { dialogWidthCh.trySend(it.width) },
211                 onDismissRequest = {},
212                 title = { Text(text = "Title") },
213                 text = { Text("Short") },
214                 confirmButton = {
215                     TextButton(onClick = { /* doSomething() */ }) { Text("Confirm") }
216                 }
217             )
218         }
219 
220         runBlocking {
221             val dialogWidth = withTimeout(5_000) { dialogWidthCh.receive() }
222             assertThat(dialogWidth).isEqualTo(minDialogWidth)
223         }
224     }
225 
226     /** Ensure a dialog with custom content has a min width. */
227     @OptIn(ExperimentalMaterial3Api::class)
228     @Test
basicAlertDialog_customContentMinWidthnull229     fun basicAlertDialog_customContentMinWidth() {
230         val dialogWidthCh = Channel<Int>(Channel.CONFLATED)
231         var minDialogWidth = 0
232         rule.setContent {
233             with(LocalDensity.current) { minDialogWidth = DialogMinWidth.roundToPx() }
234             BasicAlertDialog(onDismissRequest = {}) {
235                 Surface(modifier = Modifier.onSizeChanged { dialogWidthCh.trySend(it.width) }) {
236                     Text("Short")
237                 }
238             }
239         }
240 
241         runBlocking {
242             val dialogWidth = withTimeout(5_000) { dialogWidthCh.receive() }
243             assertThat(dialogWidth).isEqualTo(minDialogWidth)
244         }
245     }
246 
247     /** Ensure a dialog with custom content has a min width. */
248     @OptIn(ExperimentalMaterial3Api::class)
249     @Test
basicAlertDialog_customContentModifiedMinWidthnull250     fun basicAlertDialog_customContentModifiedMinWidth() {
251         val dialogWidthCh = Channel<Int>(Channel.CONFLATED)
252         var customMinDialogWidth = 0
253         rule.setContent {
254             with(LocalDensity.current) { customMinDialogWidth = 150.dp.roundToPx() }
255             BasicAlertDialog(onDismissRequest = {}, Modifier.width(width = 150.dp)) {
256                 Surface(modifier = Modifier.onSizeChanged { dialogWidthCh.trySend(it.width) }) {
257                     Text("Short")
258                 }
259             }
260         }
261 
262         runBlocking {
263             val dialogWidth = withTimeout(5_000) { dialogWidthCh.receive() }
264             assertThat(dialogWidth).isEqualTo(customMinDialogWidth)
265         }
266     }
267 
268     @Test
alertDialog_withIcon_positioningnull269     fun alertDialog_withIcon_positioning() {
270         rule.setMaterialContent(lightColorScheme()) {
271             AlertDialog(
272                 onDismissRequest = {},
273                 icon = {
274                     Icon(
275                         Icons.Filled.Favorite,
276                         contentDescription = null,
277                         modifier = Modifier.testTag(IconTestTag)
278                     )
279                 },
280                 title = { Text(text = "Title", modifier = Modifier.testTag(TitleTestTag)) },
281                 text = { Text("Text", modifier = Modifier.testTag(TextTestTag)) },
282                 confirmButton = {
283                     TextButton(
284                         onClick = { /* doSomething() */ },
285                         Modifier.testTag(ConfirmButtonTestTag).semantics(mergeDescendants = true) {}
286                     ) {
287                         Text("Confirm")
288                     }
289                 },
290                 dismissButton = {
291                     TextButton(
292                         onClick = { /* doSomething() */ },
293                         Modifier.testTag(DismissButtonTestTag).semantics(mergeDescendants = true) {}
294                     ) {
295                         Text("Dismiss")
296                     }
297                 }
298             )
299         }
300 
301         val dialogBounds = rule.onNode(isDialog()).getUnclippedBoundsInRoot()
302         val iconBounds = rule.onNodeWithTag(IconTestTag).getUnclippedBoundsInRoot()
303         val titleBounds = rule.onNodeWithTag(TitleTestTag).getUnclippedBoundsInRoot()
304         val textBounds = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()
305         val confirmBtBounds = rule.onNodeWithTag(ConfirmButtonTestTag).getUnclippedBoundsInRoot()
306         val dismissBtBounds = rule.onNodeWithTag(DismissButtonTestTag).getUnclippedBoundsInRoot()
307 
308         rule
309             .onNodeWithTag(IconTestTag)
310             // Dialog's icon should be centered (icon size is 24dp)
311             .assertLeftPositionInRootIsEqualTo((dialogBounds.width - 24.dp) / 2)
312             // Dialog's icon should be 24dp from the top
313             .assertTopPositionInRootIsEqualTo(24.dp)
314 
315         rule
316             .onNodeWithTag(TitleTestTag)
317             // Title should be centered (default alignment when an icon presence)
318             .assertLeftPositionInRootIsEqualTo((dialogBounds.width - titleBounds.width) / 2)
319             // Title should be 16dp below the icon.
320             .assertTopPositionInRootIsEqualTo(iconBounds.bottom + 16.dp)
321 
322         rule
323             .onNodeWithTag(TextTestTag)
324             // Text should be 24dp from the start.
325             .assertLeftPositionInRootIsEqualTo(24.dp)
326             // Text should be 16dp below the title.
327             .assertTopPositionInRootIsEqualTo(titleBounds.bottom + 16.dp)
328 
329         rule
330             .onNodeWithTag(ConfirmButtonTestTag)
331             // Confirm button should be 24dp from the right.
332             .assertLeftPositionInRootIsEqualTo(dialogBounds.right - 24.dp - confirmBtBounds.width)
333             // Buttons should be 24dp from the bottom (test button default height is 48dp).
334             .assertTopPositionInRootIsEqualTo(dialogBounds.bottom - 24.dp - 48.dp)
335 
336         // Check the measurements between the components.
337         (confirmBtBounds.top - textBounds.bottom).assertIsEqualTo(
338             24.dp,
339             "padding between the text and the button"
340         )
341         (confirmBtBounds.top).assertIsEqualTo(dismissBtBounds.top, "dialog buttons top alignment")
342         (confirmBtBounds.bottom).assertIsEqualTo(
343             dismissBtBounds.bottom,
344             "dialog buttons bottom alignment"
345         )
346         (confirmBtBounds.left - 8.dp).assertIsEqualTo(
347             dismissBtBounds.right,
348             "horizontal padding between the dialog buttons"
349         )
350     }
351 
352     @Test
alertDialog_positioningnull353     fun alertDialog_positioning() {
354         rule.setMaterialContent(lightColorScheme()) {
355             AlertDialog(
356                 onDismissRequest = {},
357                 title = { Text(text = "Title", modifier = Modifier.testTag(TitleTestTag)) },
358                 text = { Text("Text", modifier = Modifier.testTag(TextTestTag)) },
359                 confirmButton = {},
360                 dismissButton = {
361                     TextButton(
362                         onClick = { /* doSomething() */ },
363                         Modifier.testTag(DismissButtonTestTag).semantics(mergeDescendants = true) {}
364                     ) {
365                         Text("Dismiss")
366                     }
367                 }
368             )
369         }
370 
371         val dialogBounds = rule.onNode(isDialog()).getUnclippedBoundsInRoot()
372         val titleBounds = rule.onNodeWithTag(TitleTestTag).getUnclippedBoundsInRoot()
373         val textBounds = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()
374         val dismissBtBounds = rule.onNodeWithTag(DismissButtonTestTag).getUnclippedBoundsInRoot()
375 
376         rule
377             .onNodeWithTag(TitleTestTag)
378             // Title should 24dp from the left.
379             .assertLeftPositionInRootIsEqualTo(24.dp)
380             // Title should be 24dp from the top.
381             .assertTopPositionInRootIsEqualTo(24.dp)
382 
383         rule
384             .onNodeWithTag(TextTestTag)
385             // Text should be 24dp from the start.
386             .assertLeftPositionInRootIsEqualTo(24.dp)
387             // Text should be 16dp below the title.
388             .assertTopPositionInRootIsEqualTo(titleBounds.bottom + 16.dp)
389 
390         rule
391             .onNodeWithTag(DismissButtonTestTag)
392             // Dismiss button should be 24dp from the right.
393             .assertLeftPositionInRootIsEqualTo(dialogBounds.right - 24.dp - dismissBtBounds.width)
394             // Buttons should be 24dp from the bottom (test button default height is 48dp).
395             .assertTopPositionInRootIsEqualTo(dialogBounds.bottom - 24.dp - 48.dp)
396 
397         (dismissBtBounds.top - textBounds.bottom).assertIsEqualTo(
398             24.dp,
399             "padding between the text and the button"
400         )
401     }
402 
403     @Test
alertDialog_positioningActionsWithLongTextnull404     fun alertDialog_positioningActionsWithLongText() {
405         rule.setMaterialContent(lightColorScheme()) {
406             AlertDialog(
407                 onDismissRequest = {},
408                 title = { Text(text = "Title") },
409                 text = { Text("Text") },
410                 confirmButton = {
411                     TextButton(
412                         onClick = { /* doSomething() */ },
413                         Modifier.testTag(ConfirmButtonTestTag).semantics(mergeDescendants = true) {}
414                     ) {
415                         Text("Confirm with a long text")
416                     }
417                 },
418                 dismissButton = {
419                     TextButton(
420                         onClick = { /* doSomething() */ },
421                         Modifier.testTag(DismissButtonTestTag).semantics(mergeDescendants = true) {}
422                     ) {
423                         Text("Dismiss with a long text")
424                     }
425                 }
426             )
427         }
428 
429         val confirmBtBounds = rule.onNodeWithTag(ConfirmButtonTestTag).getUnclippedBoundsInRoot()
430         val dismissBtBounds = rule.onNodeWithTag(DismissButtonTestTag).getUnclippedBoundsInRoot()
431 
432         assert(dismissBtBounds.top > confirmBtBounds.bottom) {
433             "dismiss action should appear below the confirm action"
434         }
435     }
436 
437     @Test
alertDialog_positioningWithLazyColumnTextnull438     fun alertDialog_positioningWithLazyColumnText() {
439         rule.setMaterialContent(lightColorScheme()) {
440             AlertDialog(
441                 onDismissRequest = {},
442                 title = { Text(text = "Title", modifier = Modifier.testTag(TitleTestTag)) },
443                 text = {
444                     LazyColumn(modifier = Modifier.testTag(TextTestTag)) {
445                         items(100) { Text(text = "Message!") }
446                     }
447                 },
448                 confirmButton = {},
449                 dismissButton = {
450                     TextButton(
451                         onClick = { /* doSomething() */ },
452                         Modifier.testTag(DismissButtonTestTag).semantics(mergeDescendants = true) {}
453                     ) {
454                         Text("Dismiss")
455                     }
456                 }
457             )
458         }
459 
460         val dialogBounds = rule.onNode(isDialog()).getUnclippedBoundsInRoot()
461         val titleBounds = rule.onNodeWithTag(TitleTestTag).getUnclippedBoundsInRoot()
462         val textBounds = rule.onNodeWithTag(TextTestTag).getUnclippedBoundsInRoot()
463         val dismissBtBounds = rule.onNodeWithTag(DismissButtonTestTag).getUnclippedBoundsInRoot()
464 
465         rule
466             .onNodeWithTag(TitleTestTag)
467             // Title should 24dp from the left.
468             .assertLeftPositionInRootIsEqualTo(24.dp)
469             // Title should be 24dp from the top.
470             .assertTopPositionInRootIsEqualTo(24.dp)
471 
472         rule
473             .onNodeWithTag(TextTestTag)
474             // Text should be 24dp from the start.
475             .assertLeftPositionInRootIsEqualTo(24.dp)
476             // Text should be 16dp below the title.
477             .assertTopPositionInRootIsEqualTo(titleBounds.bottom + 16.dp)
478 
479         rule
480             .onNodeWithTag(DismissButtonTestTag)
481             // Dismiss button should be 24dp from the right.
482             .assertLeftPositionInRootIsEqualTo(dialogBounds.right - 24.dp - dismissBtBounds.width)
483             // Buttons should be 24dp from the bottom (test button default height is 48dp).
484             .assertTopPositionInRootIsEqualTo(dialogBounds.bottom - 24.dp - 48.dp)
485 
486         (dismissBtBounds.top - textBounds.bottom).assertIsEqualTo(
487             24.dp,
488             "padding between the text and the button"
489         )
490     }
491 }
492 
493 private const val IconTestTag = "icon"
494 private const val TitleTestTag = "title"
495 private const val TextTestTag = "text"
496 private const val ConfirmButtonTestTag = "confirmButton"
497 private const val DismissButtonTestTag = "dismissButton"
498