1 /* <lambda>null2 * Copyright (C) 2024 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 platform.test.motion.compose 18 19 import androidx.compose.animation.animateContentSize 20 import androidx.compose.animation.core.Animatable 21 import androidx.compose.animation.core.animateDpAsState 22 import androidx.compose.animation.core.animateFloatAsState 23 import androidx.compose.animation.core.tween 24 import androidx.compose.foundation.background 25 import androidx.compose.foundation.clickable 26 import androidx.compose.foundation.gestures.detectDragGestures 27 import androidx.compose.foundation.layout.Box 28 import androidx.compose.foundation.layout.fillMaxHeight 29 import androidx.compose.foundation.layout.height 30 import androidx.compose.foundation.layout.offset 31 import androidx.compose.foundation.layout.size 32 import androidx.compose.foundation.layout.width 33 import androidx.compose.material3.Text 34 import androidx.compose.runtime.Composable 35 import androidx.compose.runtime.LaunchedEffect 36 import androidx.compose.runtime.getValue 37 import androidx.compose.runtime.mutableStateOf 38 import androidx.compose.runtime.remember 39 import androidx.compose.runtime.setValue 40 import androidx.compose.ui.Modifier 41 import androidx.compose.ui.draw.drawBehind 42 import androidx.compose.ui.geometry.Offset 43 import androidx.compose.ui.geometry.isSpecified 44 import androidx.compose.ui.geometry.lerp 45 import androidx.compose.ui.graphics.Color 46 import androidx.compose.ui.graphics.graphicsLayer 47 import androidx.compose.ui.input.pointer.pointerInput 48 import androidx.compose.ui.platform.testTag 49 import androidx.compose.ui.test.click 50 import androidx.compose.ui.test.hasTestTag 51 import androidx.compose.ui.test.onNodeWithTag 52 import androidx.compose.ui.test.performTouchInput 53 import androidx.compose.ui.test.swipeDown 54 import androidx.compose.ui.unit.dp 55 import androidx.test.ext.junit.runners.AndroidJUnit4 56 import com.google.common.truth.IterableSubject 57 import com.google.common.truth.Truth.assertThat 58 import kotlin.time.Duration.Companion.milliseconds 59 import kotlinx.coroutines.launch 60 import org.junit.Rule 61 import org.junit.Test 62 import org.junit.runner.RunWith 63 import platform.test.motion.MotionTestRule 64 import platform.test.motion.compose.DataPointTypes.offset 65 import platform.test.motion.compose.values.MotionTestValueKey 66 import platform.test.motion.compose.values.MotionTestValues 67 import platform.test.motion.compose.values.motionTestValues 68 import platform.test.motion.golden.DataPointTypes 69 import platform.test.motion.golden.NotFoundDataPoint 70 import platform.test.motion.golden.ValueDataPoint 71 import platform.test.motion.testing.createGoldenPathManager 72 73 @RunWith(AndroidJUnit4::class) 74 class ComposeToolkitTest { 75 private val pathManager = 76 createGoldenPathManager("platform_testing/libraries/motion/compose/tests/goldens") 77 @get:Rule val motionRule = createFixedConfigurationComposeMotionTestRule(pathManager) 78 79 @Test 80 fun recordMotion_capturePosition() = 81 motionRule.runTest { 82 var completed = false 83 84 val motion = 85 recordMotion( 86 content = { play -> 87 val offset by 88 animateDpAsState(if (play) 90.dp else 0.dp) { completed = true } 89 Box( 90 modifier = 91 Modifier.offset(x = offset) 92 .testTag("foo") 93 .size(10.dp) 94 .background(Color.Red) 95 ) 96 }, 97 ComposeRecordingSpec.until({ completed }) { 98 feature(hasTestTag("foo"), ComposeFeatureCaptures.positionInRoot) 99 }, 100 ) 101 102 assertThat(motion).timeSeriesMatchesGolden() 103 } 104 105 @Test 106 fun recordMotion_captureSize() = 107 motionRule.runTest { 108 var completed = false 109 110 val motion = 111 recordMotion( 112 content = { play -> 113 Box( 114 modifier = 115 Modifier.testTag("foo") 116 .animateContentSize { _, _ -> completed = true } 117 .width(if (play) 90.dp else 10.dp) 118 .height(10.dp) 119 .background(Color.Red) 120 ) 121 }, 122 ComposeRecordingSpec.until({ completed }) { 123 feature(hasTestTag("foo"), ComposeFeatureCaptures.dpSize) 124 }, 125 ) 126 127 assertThat(motion).timeSeriesMatchesGolden() 128 } 129 130 @Test 131 fun recordMotion_captureAlpha() = 132 motionRule.runTest { 133 var completed = false 134 135 val motion = 136 recordMotion( 137 content = { play -> 138 val opacity by 139 animateFloatAsState(if (play) 1f else 0f) { completed = true } 140 Box( 141 modifier = 142 Modifier.graphicsLayer { alpha = opacity } 143 .size(10.dp) 144 .fillMaxHeight() 145 .background(Color.Red) 146 .testTag("BoxOfInterest") 147 .motionTestValues { opacity exportAs MotionTestValues.alpha } 148 ) 149 }, 150 ComposeRecordingSpec.until({ completed }) { 151 feature(hasTestTag("BoxOfInterest"), ComposeFeatureCaptures.alpha) 152 }, 153 ) 154 155 assertThat(motion).timeSeriesMatchesGolden() 156 } 157 158 @Test 159 fun recordMotion_captureCrossfade() = 160 motionRule.runTest { 161 var completed = false 162 163 val motion = 164 recordMotion( 165 content = { play -> 166 val opacity by 167 animateFloatAsState(if (play) 1f else 0f) { completed = true } 168 169 Box( 170 modifier = 171 Modifier.graphicsLayer { alpha = opacity } 172 .size(10.dp) 173 .fillMaxHeight() 174 .background(Color.Red) 175 .testTag("bar") 176 .motionTestValues { opacity exportAs MotionTestValues.alpha } 177 ) 178 Box( 179 modifier = 180 Modifier.graphicsLayer { alpha = 1 - opacity } 181 .size(10.dp) 182 .fillMaxHeight() 183 .background(Color.Blue) 184 .testTag("foo") 185 .motionTestValues { 186 (1f - opacity) exportAs MotionTestValues.alpha 187 } 188 ) 189 }, 190 ComposeRecordingSpec.until({ completed }) { 191 feature(hasTestTag("bar"), ComposeFeatureCaptures.alpha, name = "bar_alpha") 192 feature(hasTestTag("foo"), ComposeFeatureCaptures.alpha, name = "foo_alpha") 193 }, 194 ) 195 196 assertThat(motion).timeSeriesMatchesGolden() 197 } 198 199 @Test 200 fun recordMotion_motionControl_performTap() = 201 motionRule.runTest { 202 val motion = 203 recordMotion( 204 content = { 205 var clickCount by remember { mutableStateOf(0) } 206 val animatedSize by animateDpAsState(targetValue = 20.dp * (clickCount + 1)) 207 Box( 208 modifier = 209 Modifier.clickable { clickCount++ } 210 .testTag("foo") 211 .width(animatedSize) 212 .height(20.dp) 213 ) 214 }, 215 ComposeRecordingSpec( 216 recording = { 217 awaitFrames(1) 218 onNodeWithTag("foo").performTouchInput { click() } 219 // Ideally, there would be a way to await the end of all pending 220 // animations. That is currently not possible (while also manually 221 // advancing the time, see http://shortn/_3WkhxugOzv), so this waits 222 // for a fixed duration. Since the tests are stable, the animation 223 // duration will always be the same - and if it changes, one has to 224 // update the golden anyways, so this is not a huge issue. 225 // Alternatively, the production code has to be instrumented, and track 226 // the animation end manually. 227 awaitDelay(300.milliseconds) 228 onNodeWithTag("foo").performTouchInput { click() } 229 awaitDelay(300.milliseconds) 230 } 231 ) { 232 feature(hasTestTag("foo"), ComposeFeatureCaptures.dpSize) 233 }, 234 ) 235 motionRule.assertThat(motion).timeSeriesMatchesGolden() 236 } 237 238 @Composable 239 private fun DraggableContent() { 240 var pointerPosition by remember { mutableStateOf(Offset.Unspecified) } 241 242 Box( 243 modifier = 244 Modifier.pointerInput(Unit) { 245 detectDragGestures( 246 onDragStart = { pointerPosition = it }, 247 onDragEnd = { pointerPosition = Offset.Unspecified }, 248 ) { _, dragAmount -> 249 pointerPosition = 250 if (pointerPosition.isSpecified) pointerPosition + dragAmount 251 else dragAmount 252 } 253 } 254 .testTag("foo") 255 .motionTestValues { pointerPosition exportAs pointerOffsetKey } 256 .size(300.dp) 257 .drawBehind { 258 if (pointerPosition.isSpecified) { 259 drawCircle(Color.Red, radius = 5.dp.toPx(), center = pointerPosition) 260 } 261 } 262 ) 263 } 264 265 @Test 266 fun recordMotion_motionControl_performGesture_notRecordableWithDefaultImplementations() = 267 motionRule.runTest { 268 val motion = 269 recordMotion( 270 content = { DraggableContent() }, 271 ComposeRecordingSpec( 272 recording = { 273 // For the purpose of this test, make sure one frame is recorded before 274 // the (undesired) blocking swipeDown call is performed. That ensures 275 // that the golden will actually indicate the skipped frames. 276 awaitFrames(1) 277 278 onNodeWithTag("foo").performTouchInput { 279 // The regular gesture functions do advance time internally; 280 // the motion test will not record frames while performing the 281 // gesture. 282 // It might still be useful to get the test subject into some other 283 // state. 284 swipeDown(durationMillis = 100, startY = top) 285 } 286 awaitDelay(200.milliseconds) 287 } 288 ) { 289 feature(pointerOffsetKey, offset) 290 }, 291 ) 292 293 // The golden is expected to be missing the first 100ms, while the `swipeDown` executes. 294 motionRule.assertThat(motion).timeSeriesMatchesGolden() 295 } 296 297 @Test 298 fun recordMotion_motionControl_performGestureAsync() = 299 motionRule.runTest { 300 val motion = 301 recordMotion( 302 content = { DraggableContent() }, 303 ComposeRecordingSpec( 304 recording = { 305 performTouchInputAsync(onNodeWithTag("foo")) { 306 swipeDown(durationMillis = 100) 307 } 308 // Await an extra frame to capture the up event in the golden 309 awaitFrames(1) 310 } 311 ) { 312 feature(pointerOffsetKey, offset) 313 }, 314 ) 315 316 motionRule.assertThat(motion).timeSeriesMatchesGolden() 317 } 318 319 @Test 320 fun recordMotion_motionControl_performGesture_sendIndividualEvents() = 321 motionRule.runTest { 322 val motion = 323 recordMotion( 324 content = { DraggableContent() }, 325 ComposeRecordingSpec( 326 recording = { 327 val dragSurface = onNodeWithTag("foo") 328 329 // Motion tests that record the results of a gesture must send 330 // individual events. 331 // TODO(b/322324387): Either work with compose to allow using regular 332 // test gesture control, or at least supply the similar convenience 333 // helper functions. 334 dragSurface.performTouchInput { down(topCenter) } 335 repeat(20) { i -> 336 dragSurface.performTouchInput { 337 moveTo(lerp(topCenter, bottomCenter, i / 20f), delayMillis = 0) 338 } 339 awaitFrames(1) 340 } 341 dragSurface.performTouchInput { up() } 342 } 343 ) { 344 feature(pointerOffsetKey, offset) 345 }, 346 ) 347 348 motionRule.assertThat(motion).timeSeriesMatchesGolden() 349 } 350 351 /** 352 * Helper to assert the exact timing of the recording. 353 * 354 * Returns an [IterableSubject] identifying the frames *recorded*. 355 * 356 * This helper runs `recordMotion(testContent, motionControl)`. The testContent is Composable 357 * that increases a frame counter on each animation frame. A time series of that frame count 358 * value is what is captured and returned for assertion on the [IterableSubject]. 359 * 360 * The frame count will start at 0 for the first composition, and increase up to 16, at which 361 * point the `testContent` composable will be idle. 362 * 363 * The observed frame counts is offset by `100` as soon as the `play` flag is true, to make it 364 * easy to assert the exact timing of the flipping. When [recordBefore] or [recordAfter] are set 365 * to true, the first/last entry in the returned sequence denote the recorded before/after 366 * frames. 367 * 368 * The actual captured frames reflect the internals of the motion test, as the 369 * [MotionControlImpl] cycles through the [MotionControlState]s. The following chart illustrates 370 * this. 371 * 372 * ``` 373 * Clock : 0ms 16ms 32ms 48ms ... 256ms 374 * Frame count : 0 1 2 103 ... 116 375 * Animation Time: - 0ms 16ms 32ms ... 240ms 376 * Events : │ │ │ │ │ └ testContent is idle (animation finished) 377 * │ │ │ │ └ motionControl in `Recording` state 378 * │ │ │ └ before state captured, flipping `play` to true 379 * │ │ └ motionControl in `ReadyToPlay` 380 * │ └ Animation first frame (value 0), motionControl in `Start` state 381 * └ testContent enters composition, rule calls `waitForIdle` 382 * ``` 383 */ 384 private fun MotionTestRule<ComposeToolkit>.assertThatFrameCountValues( 385 recordBefore: Boolean, 386 recordAfter: Boolean, 387 motionControl: MotionControl, 388 ) = assertThatFrameCountValuesImpl(recordBefore, recordAfter, motionControl) 389 390 @Test 391 fun recordMotion_motionControl_recordDurationOnly() = 392 motionRule.runTest { 393 assertThatFrameCountValues( 394 recordBefore = true, 395 recordAfter = false, 396 MotionControl { awaitFrames(5) }, 397 ) 398 // Minimum delays, play flag flipped after 2 399 .containsExactly(/* before */ 2, 103, 104, 105, 106, 107) 400 .inOrder() 401 } 402 403 @Test 404 fun recordMotion_motionControl_recordDurationOnly_withoutBefore() = 405 motionRule.runTest { 406 assertThatFrameCountValues( 407 recordBefore = false, 408 recordAfter = false, 409 MotionControl { awaitFrames(5) }, 410 ) 411 // Same as above, just not recording before. Must not make a difference 412 .containsExactly(103, 104, 105, 106, 107) 413 .inOrder() 414 } 415 416 @Test 417 fun recordMotion_motionControl_recordBeforeAndAfter() = 418 motionRule.runTest { 419 assertThatFrameCountValues( 420 recordBefore = true, 421 recordAfter = true, 422 MotionControl { awaitFrames(1) }, 423 ) 424 // after represents the state when the composable is idle, no matter how long the 425 // recording took 426 .containsExactly(/* before */ 2, 103, /* after */ 116) 427 .inOrder() 428 } 429 430 @Test 431 fun recordMotion_motionControl_delayStartRecording() = 432 motionRule.runTest { 433 assertThatFrameCountValues( 434 recordBefore = true, 435 recordAfter = false, 436 MotionControl(delayRecording = { awaitFrames(2) }) { awaitFrames(5) }, 437 ) 438 // Start recording is delayed, readyToPlay is still after frame 2 (before is 439 // captured 440 // just before the flag is flipped) 441 .containsExactly(/* before */ 2, 105, 106, 107, 108, 109) 442 .inOrder() 443 } 444 445 @Test 446 fun recordMotion_motionControl_delayReadyToPlay() = 447 motionRule.runTest { 448 assertThatFrameCountValues( 449 recordBefore = true, 450 recordAfter = false, 451 MotionControl(delayReadyToPlay = { awaitFrames(2) }) { awaitFrames(5) }, 452 ) 453 // delaying readyToPlay pushes back the before recording 454 .containsExactly(/* before */ 4, 105, 106, 107, 108, 109) 455 .inOrder() 456 } 457 458 @Test 459 fun recordMotion_motionControl_delayPlayAndRecording() = 460 motionRule.runTest { 461 assertThatFrameCountValues( 462 recordBefore = true, 463 recordAfter = false, 464 MotionControl( 465 delayReadyToPlay = { awaitFrames(2) }, 466 delayRecording = { awaitFrames(3) }, 467 ) { 468 awaitFrames(5) 469 }, 470 ) 471 .containsExactly(/* before */ 4, 108, 109, 110, 111, 112) 472 .inOrder() 473 } 474 475 @Test 476 fun recordMotion_motionControl_awaitDelay_10ms_skipsOneFrame() = 477 motionRule.runTest { 478 assertThatFrameCountValues( 479 recordBefore = false, 480 recordAfter = false, 481 MotionControl { awaitDelay(10.milliseconds) }, 482 ) 483 .hasSize(1) 484 } 485 486 @Test 487 fun recordMotion_motionControl_awaitDelay_16ms_skipsOneFrame() = 488 motionRule.runTest { 489 assertThatFrameCountValues( 490 recordBefore = false, 491 recordAfter = false, 492 MotionControl { awaitDelay(16.milliseconds) }, 493 ) 494 .hasSize(1) 495 } 496 497 @Test 498 fun recordMotion_motionControl_awaitDelay_17ms_skipsTwoFrames() = 499 motionRule.runTest { 500 assertThatFrameCountValues( 501 recordBefore = false, 502 recordAfter = false, 503 MotionControl { awaitDelay(17.milliseconds) }, 504 ) 505 .hasSize(2) 506 } 507 508 @Test 509 fun recordMotion_motionControl_awaitDelay_delayStartsAfterImmediately() = 510 motionRule.runTest { 511 assertThatFrameCountValues( 512 recordBefore = false, 513 recordAfter = false, 514 MotionControl { 515 // 20ms 516 awaitDelay(10.milliseconds) 517 awaitDelay(10.milliseconds) 518 }, 519 ) 520 .hasSize(2) 521 } 522 523 @Test 524 fun recordMotion_motionControl_awaitDelay_roundsUpToFullDelay() = 525 motionRule.runTest { 526 assertThatFrameCountValues( 527 recordBefore = false, 528 recordAfter = false, 529 MotionControl { awaitDelay(10.milliseconds) }, 530 ) 531 .hasSize(1) 532 } 533 534 @Test 535 fun recordMotion_motionControl_awaitCondition() = 536 motionRule.runTest { 537 val checkConditionInvocationFrames = mutableListOf<Int>() 538 539 assertThatFrameCountValues( 540 recordBefore = true, 541 recordAfter = false, 542 MotionControl { 543 awaitCondition { 544 val currentFrameCount = motionTestValueOfNode(frameCountKey) 545 checkConditionInvocationFrames.add(currentFrameCount) 546 currentFrameCount == 105 547 } 548 }, 549 ) 550 // Must not record the frame where the condition returned true 551 .containsExactly(/* before */ 2, 103, 104) 552 .inOrder() 553 554 assertThat(checkConditionInvocationFrames).containsExactly(103, 104, 105).inOrder() 555 } 556 557 @Test 558 fun recordMotion_motionControl_awaitConditionOnSignals() = 559 motionRule.runTest { 560 val awaitReadyToPlayInvocationFrames = mutableListOf<Int>() 561 val awaitStartRecordingInvocationFrames = mutableListOf<Int>() 562 val awaitAnimationEndInvocationFrames = mutableListOf<Int>() 563 564 assertThatFrameCountValues( 565 recordBefore = true, 566 recordAfter = false, 567 MotionControl( 568 delayReadyToPlay = { 569 awaitCondition { 570 motionTestValueOfNode(frameCountKey) 571 .also(awaitReadyToPlayInvocationFrames::add) == 5 572 } 573 }, 574 delayRecording = { 575 awaitCondition { 576 motionTestValueOfNode(frameCountKey) 577 .also(awaitStartRecordingInvocationFrames::add) == 107 578 } 579 }, 580 ) { 581 awaitCondition { 582 motionTestValueOfNode(frameCountKey) 583 .also(awaitAnimationEndInvocationFrames::add) == 110 584 } 585 }, 586 ) 587 .containsExactly(/* before */ 6, 108, 109) 588 .inOrder() 589 590 assertThat(awaitReadyToPlayInvocationFrames).containsExactly(1, 2, 3, 4, 5).inOrder() 591 // 6 (and not 106) as this method is invoked immediately after readyToPlay was flipped, 592 // and no recomposition happened yet. 593 assertThat(awaitStartRecordingInvocationFrames).containsExactly(6, 107).inOrder() 594 assertThat(awaitAnimationEndInvocationFrames).containsExactly(108, 109, 110).inOrder() 595 } 596 597 /** @see assertThatFrameCountValues */ 598 private fun MotionTestRule<ComposeToolkit>.assertThatFrameCountValuesImpl( 599 recordBefore: Boolean, 600 recordAfter: Boolean, 601 motionControl: MotionControl, 602 ): IterableSubject { 603 val motion = 604 recordMotion( 605 content = { play -> 606 var frameCount by remember { mutableStateOf(0) } 607 LaunchedEffect(Unit) { 608 val animatable = Animatable(0f) 609 launch { 610 // frameCount must end at 16 when the animation ends (some tests 611 // capture this) 612 val testMaxFrameCount = 16 613 614 // Compute the animation duration that ensures the callback will be 615 // invoked exactly testMaxFrameCount: Compose runs test animations at 616 // exactly 16ms/frame, and the first animation frame is for animation 617 // value 0. 618 val animationDurationMillis = (testMaxFrameCount - 1) * 16 619 animatable.animateTo(1f, tween(animationDurationMillis)) { 620 frameCount++ 621 } 622 } 623 } 624 val exportedFrameCount = if (play) frameCount + 100 else frameCount 625 Text( 626 text = "$exportedFrameCount", 627 modifier = 628 Modifier.motionTestValues { exportedFrameCount exportAs frameCountKey }, 629 ) 630 }, 631 ComposeRecordingSpec(motionControl, recordBefore, recordAfter) { 632 feature(frameCountKey, DataPointTypes.int) 633 }, 634 ) 635 636 val frameCountValues = 637 checkNotNull(motion.timeSeries.features["frameCount"]).dataPoints.map { dataPoint -> 638 when (dataPoint) { 639 is ValueDataPoint -> dataPoint.value 640 is NotFoundDataPoint -> null 641 else -> throw AssertionError() 642 } 643 } 644 645 return assertThat(frameCountValues) 646 } 647 648 companion object { 649 private val frameCountKey = MotionTestValueKey<Int>("frameCount") 650 private val pointerOffsetKey = MotionTestValueKey<Offset>("pointerOffset") 651 } 652 } 653