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