1 /* <lambda>null2 * Copyright (C) 2023 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 com.android.compose.animation.scene 18 19 import android.util.Log 20 import androidx.compose.foundation.gestures.Orientation 21 import androidx.compose.runtime.mutableStateOf 22 import androidx.compose.ui.test.junit4.createComposeRule 23 import androidx.test.ext.junit.runners.AndroidJUnit4 24 import com.android.compose.animation.scene.TestScenes.SceneA 25 import com.android.compose.animation.scene.TestScenes.SceneB 26 import com.android.compose.animation.scene.TestScenes.SceneC 27 import com.android.compose.animation.scene.content.state.TransitionState 28 import com.android.compose.animation.scene.subjects.assertThat 29 import com.android.compose.animation.scene.transition.seekToScene 30 import com.android.compose.test.MonotonicClockTestScope 31 import com.android.compose.test.TestSceneTransition 32 import com.android.compose.test.runMonotonicClockTest 33 import com.android.compose.test.transition 34 import com.google.common.truth.Truth.assertThat 35 import kotlin.coroutines.cancellation.CancellationException 36 import kotlinx.coroutines.CompletableDeferred 37 import kotlinx.coroutines.CoroutineStart 38 import kotlinx.coroutines.awaitCancellation 39 import kotlinx.coroutines.cancelAndJoin 40 import kotlinx.coroutines.channels.Channel 41 import kotlinx.coroutines.flow.consumeAsFlow 42 import kotlinx.coroutines.launch 43 import kotlinx.coroutines.runBlocking 44 import kotlinx.coroutines.test.runCurrent 45 import kotlinx.coroutines.test.runTest 46 import org.junit.Assert.assertThrows 47 import org.junit.Rule 48 import org.junit.Test 49 import org.junit.runner.RunWith 50 51 @RunWith(AndroidJUnit4::class) 52 class SceneTransitionLayoutStateTest { 53 @get:Rule val rule = createComposeRule() 54 55 @Test 56 fun isTransitioningTo_idle() { 57 val state = MutableSceneTransitionLayoutStateImpl(SceneA, SceneTransitions.Empty) 58 59 assertThat(state.isTransitioning()).isFalse() 60 assertThat(state.isTransitioning(from = SceneA)).isFalse() 61 assertThat(state.isTransitioning(to = SceneB)).isFalse() 62 assertThat(state.isTransitioning(from = SceneA, to = SceneB)).isFalse() 63 } 64 65 @Test 66 fun isTransitioningTo_transition() = runTest { 67 val state = MutableSceneTransitionLayoutStateImpl(SceneA, SceneTransitions.Empty) 68 state.startTransitionImmediately( 69 animationScope = backgroundScope, 70 transition(from = SceneA, to = SceneB), 71 ) 72 73 assertThat(state.isTransitioning()).isTrue() 74 assertThat(state.isTransitioning(from = SceneA)).isTrue() 75 assertThat(state.isTransitioning(from = SceneB)).isFalse() 76 assertThat(state.isTransitioning(to = SceneB)).isTrue() 77 assertThat(state.isTransitioning(to = SceneA)).isFalse() 78 assertThat(state.isTransitioning(from = SceneA, to = SceneB)).isTrue() 79 } 80 81 @Test 82 fun setTargetScene_idleToSameScene() = runMonotonicClockTest { 83 val state = MutableSceneTransitionLayoutState(SceneA) 84 assertThat(state.setTargetScene(SceneA, animationScope = this)).isNull() 85 } 86 87 @Test 88 fun setTargetScene_idleToDifferentScene() = runMonotonicClockTest { 89 val state = MutableSceneTransitionLayoutState(SceneA) 90 val (transition, job) = checkNotNull(state.setTargetScene(SceneB, animationScope = this)) 91 assertThat(state.transitionState).isEqualTo(transition) 92 93 job.join() 94 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB)) 95 } 96 97 @Test 98 fun setTargetScene_transitionToSameScene() = runMonotonicClockTest { 99 val state = MutableSceneTransitionLayoutState(SceneA) 100 101 val (_, job) = checkNotNull(state.setTargetScene(SceneB, animationScope = this)) 102 assertThat(state.setTargetScene(SceneB, animationScope = this)).isNull() 103 104 job.join() 105 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB)) 106 } 107 108 @Test 109 fun setTargetScene_transitionToDifferentScene() = runMonotonicClockTest { 110 val state = MutableSceneTransitionLayoutState(SceneA) 111 112 assertThat(state.setTargetScene(SceneB, animationScope = this)).isNotNull() 113 val (_, job) = checkNotNull(state.setTargetScene(SceneC, animationScope = this)) 114 115 job.join() 116 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneC)) 117 } 118 119 @Test 120 fun setTargetScene_coroutineScopeCancelled() = runMonotonicClockTest { 121 val state = MutableSceneTransitionLayoutState(SceneA) 122 123 lateinit var transition: TransitionState.Transition 124 val job = 125 launch(start = CoroutineStart.UNDISPATCHED) { 126 transition = checkNotNull(state.setTargetScene(SceneB, animationScope = this)).first 127 } 128 assertThat(state.transitionState).isEqualTo(transition) 129 130 // Cancelling the scope/job still sets the state to Idle(targetScene). 131 job.cancelAndJoin() 132 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB)) 133 } 134 135 @Test 136 fun setTargetScene_withTransitionKey() = runMonotonicClockTest { 137 val transitionkey = TransitionKey(debugName = "foo") 138 val state = 139 MutableSceneTransitionLayoutState( 140 SceneA, 141 transitions = 142 transitions { 143 from(SceneA, to = SceneB) { fade(TestElements.Foo) } 144 from(SceneA, to = SceneB, key = transitionkey) { 145 fade(TestElements.Foo) 146 fade(TestElements.Bar) 147 } 148 }, 149 ) 150 151 // Default transition from A to B. 152 assertThat(state.setTargetScene(SceneB, animationScope = this)).isNotNull() 153 assertThat(state.currentTransition?.transformationSpec?.transformationMatchers).hasSize(1) 154 155 // Go back to A. 156 state.setTargetScene(SceneA, animationScope = this) 157 testScheduler.advanceUntilIdle() 158 assertThat(state.transitionState).isIdle() 159 assertThat(state.transitionState).hasCurrentScene(SceneA) 160 161 // Specific transition from A to B. 162 assertThat( 163 state.setTargetScene(SceneB, animationScope = this, transitionKey = transitionkey) 164 ) 165 .isNotNull() 166 assertThat(state.currentTransition?.transformationSpec?.transformationMatchers).hasSize(2) 167 } 168 169 private fun MonotonicClockTestScope.startOverscrollableTransistionFromAtoB( 170 progress: () -> Float, 171 sceneTransitions: SceneTransitions, 172 ): MutableSceneTransitionLayoutStateImpl { 173 val state = MutableSceneTransitionLayoutStateImpl(SceneA, sceneTransitions) 174 state.startTransitionImmediately( 175 animationScope = backgroundScope, 176 transition( 177 from = SceneA, 178 to = SceneB, 179 progress = progress, 180 orientation = Orientation.Vertical, 181 ), 182 ) 183 assertThat(state.isTransitioning()).isTrue() 184 return state 185 } 186 187 @Test 188 fun overscrollDsl_definedForToScene() = runMonotonicClockTest { 189 val progress = mutableStateOf(0f) 190 val state = 191 startOverscrollableTransistionFromAtoB( 192 progress = { progress.value }, 193 sceneTransitions = 194 transitions { 195 overscroll(SceneB, Orientation.Vertical) { fade(TestElements.Foo) } 196 }, 197 ) 198 val transition = assertThat(state.transitionState).isSceneTransition() 199 assertThat(transition).hasNoOverscrollSpec() 200 201 // overscroll for SceneA is NOT defined 202 progress.value = -0.1f 203 assertThat(transition).hasNoOverscrollSpec() 204 205 // scroll from SceneA to SceneB 206 progress.value = 0.5f 207 assertThat(transition).hasNoOverscrollSpec() 208 209 progress.value = 1f 210 assertThat(transition).hasNoOverscrollSpec() 211 212 // overscroll for SceneB is defined 213 progress.value = 1.1f 214 val overscrollSpec = assertThat(transition).hasOverscrollSpec() 215 assertThat(overscrollSpec.content).isEqualTo(SceneB) 216 } 217 218 @Test 219 fun overscrollDsl_definedForFromScene() = runMonotonicClockTest { 220 val progress = mutableStateOf(0f) 221 val state = 222 startOverscrollableTransistionFromAtoB( 223 progress = { progress.value }, 224 sceneTransitions = 225 transitions { 226 overscroll(SceneA, Orientation.Vertical) { fade(TestElements.Foo) } 227 }, 228 ) 229 230 val transition = assertThat(state.transitionState).isSceneTransition() 231 assertThat(transition).hasNoOverscrollSpec() 232 233 // overscroll for SceneA is defined 234 progress.value = -0.1f 235 val overscrollSpec = assertThat(transition).hasOverscrollSpec() 236 assertThat(overscrollSpec.content).isEqualTo(SceneA) 237 238 // scroll from SceneA to SceneB 239 progress.value = 0.5f 240 assertThat(transition).hasNoOverscrollSpec() 241 242 progress.value = 1f 243 assertThat(transition).hasNoOverscrollSpec() 244 245 // overscroll for SceneB is NOT defined 246 progress.value = 1.1f 247 assertThat(transition).hasNoOverscrollSpec() 248 } 249 250 @Test 251 fun overscrollDsl_notDefinedScenes() = runMonotonicClockTest { 252 val progress = mutableStateOf(0f) 253 val state = 254 startOverscrollableTransistionFromAtoB( 255 progress = { progress.value }, 256 sceneTransitions = transitions {}, 257 ) 258 259 val transition = assertThat(state.transitionState).isSceneTransition() 260 assertThat(transition).hasNoOverscrollSpec() 261 262 // overscroll for SceneA is NOT defined 263 progress.value = -0.1f 264 assertThat(transition).hasNoOverscrollSpec() 265 266 // scroll from SceneA to SceneB 267 progress.value = 0.5f 268 assertThat(transition).hasNoOverscrollSpec() 269 270 progress.value = 1f 271 assertThat(transition).hasNoOverscrollSpec() 272 273 // overscroll for SceneB is NOT defined 274 progress.value = 1.1f 275 assertThat(transition).hasNoOverscrollSpec() 276 } 277 278 @Test 279 fun multipleTransitions() = runTest { 280 val frozenTransitions = mutableSetOf<TestSceneTransition>() 281 fun onFreezeAndAnimate(transition: TestSceneTransition): () -> Unit { 282 // Instead of letting the transition finish when it is frozen, we put the transition in 283 // the frozenTransitions set so that we can verify that freezeAndAnimateToCurrentState() 284 // is called when expected and then we call finish() ourselves to finish the 285 // transitions. 286 frozenTransitions.add(transition) 287 288 return { /* do nothing */ } 289 } 290 291 val state = MutableSceneTransitionLayoutStateImpl(SceneA, EmptyTestTransitions) 292 val aToB = transition(SceneA, SceneB, onFreezeAndAnimate = ::onFreezeAndAnimate) 293 val bToC = transition(SceneB, SceneC, onFreezeAndAnimate = ::onFreezeAndAnimate) 294 val cToA = transition(SceneC, SceneA, onFreezeAndAnimate = ::onFreezeAndAnimate) 295 296 // Starting state. 297 assertThat(frozenTransitions).isEmpty() 298 assertThat(state.currentTransitions).isEmpty() 299 300 // A => B. 301 val aToBJob = state.startTransitionImmediately(animationScope = backgroundScope, aToB) 302 assertThat(frozenTransitions).isEmpty() 303 assertThat(state.finishedTransitions).isEmpty() 304 assertThat(state.currentTransitions).containsExactly(aToB).inOrder() 305 306 // B => C. This should automatically call freezeAndAnimateToCurrentState() on aToB. 307 val bToCJob = state.startTransitionImmediately(animationScope = backgroundScope, bToC) 308 assertThat(frozenTransitions).containsExactly(aToB) 309 assertThat(state.finishedTransitions).isEmpty() 310 assertThat(state.currentTransitions).containsExactly(aToB, bToC).inOrder() 311 312 // C => A. This should automatically call freezeAndAnimateToCurrentState() on bToC. 313 state.startTransitionImmediately(animationScope = backgroundScope, cToA) 314 assertThat(frozenTransitions).containsExactly(aToB, bToC) 315 assertThat(state.finishedTransitions).isEmpty() 316 assertThat(state.currentTransitions).containsExactly(aToB, bToC, cToA).inOrder() 317 318 // Mark bToC as finished. The list of current transitions does not change because aToB is 319 // still not marked as finished. 320 bToC.finish() 321 bToCJob.join() 322 assertThat(state.finishedTransitions).containsExactly(bToC) 323 assertThat(state.currentTransitions).containsExactly(aToB, bToC, cToA).inOrder() 324 325 // Mark aToB as finished. This will remove both aToB and bToC from the list of transitions. 326 aToB.finish() 327 aToBJob.join() 328 assertThat(state.finishedTransitions).isEmpty() 329 assertThat(state.currentTransitions).containsExactly(cToA).inOrder() 330 } 331 332 @Test 333 fun tooManyTransitionsLogsWtfAndClearsTransitions() = runTest { 334 val state = MutableSceneTransitionLayoutStateImpl(SceneA, EmptyTestTransitions) 335 336 fun startTransition() { 337 val transition = 338 transition(SceneA, SceneB, onFreezeAndAnimate = { launch { /* do nothing */ } }) 339 state.startTransitionImmediately(animationScope = backgroundScope, transition) 340 } 341 342 var hasLoggedWtf = false 343 val originalHandler = Log.setWtfHandler { _, _, _ -> hasLoggedWtf = true } 344 try { 345 repeat(100) { startTransition() } 346 assertThat(hasLoggedWtf).isFalse() 347 assertThat(state.currentTransitions).hasSize(100) 348 349 startTransition() 350 assertThat(hasLoggedWtf).isTrue() 351 assertThat(state.currentTransitions).hasSize(1) 352 } finally { 353 Log.setWtfHandler(originalHandler) 354 } 355 } 356 357 @Test 358 fun snapToScene() = runMonotonicClockTest { 359 val state = MutableSceneTransitionLayoutState(SceneA) 360 361 // Transition to B. 362 state.setTargetScene(SceneB, animationScope = this) 363 val transition = assertThat(state.transitionState).isSceneTransition() 364 assertThat(transition).hasCurrentScene(SceneB) 365 366 // Snap to C. 367 state.snapToScene(SceneC) 368 assertThat(state.transitionState).isIdle() 369 assertThat(state.transitionState).hasCurrentScene(SceneC) 370 } 371 372 @Test 373 fun snapToScene_freezesCurrentTransition() = runMonotonicClockTest { 374 val state = MutableSceneTransitionLayoutStateImpl(SceneA) 375 376 // Start a transition that is never finished. We don't use backgroundScope on purpose so 377 // that this test would fail if the transition was not frozen when snapping. 378 state.startTransitionImmediately(animationScope = this, transition(SceneA, SceneB)) 379 val transition = assertThat(state.transitionState).isSceneTransition() 380 assertThat(transition).hasFromScene(SceneA) 381 assertThat(transition).hasToScene(SceneB) 382 383 // Snap to C. 384 state.snapToScene(SceneC) 385 assertThat(state.transitionState).isIdle() 386 assertThat(state.transitionState).hasCurrentScene(SceneC) 387 } 388 389 @Test 390 fun seekToScene() = runMonotonicClockTest { 391 val state = MutableSceneTransitionLayoutState(SceneA) 392 val progress = Channel<Float>() 393 394 val job = 395 launch(start = CoroutineStart.UNDISPATCHED) { 396 state.seekToScene(SceneB, progress.consumeAsFlow()) 397 } 398 399 val transition = assertThat(state.transitionState).isSceneTransition() 400 assertThat(transition).hasFromScene(SceneA) 401 assertThat(transition).hasToScene(SceneB) 402 assertThat(transition).hasProgress(0f) 403 404 // Change progress. 405 progress.send(0.4f) 406 assertThat(transition).hasProgress(0.4f) 407 408 // Close the channel normally to confirm the transition. 409 progress.close() 410 job.join() 411 assertThat(state.transitionState).isIdle() 412 assertThat(state.transitionState).hasCurrentScene(SceneB) 413 } 414 415 @Test 416 fun seekToScene_cancelled() = runMonotonicClockTest { 417 val state = MutableSceneTransitionLayoutState(SceneA) 418 val progress = Channel<Float>() 419 420 val job = 421 launch(start = CoroutineStart.UNDISPATCHED) { 422 state.seekToScene(SceneB, progress.consumeAsFlow()) 423 } 424 425 val transition = assertThat(state.transitionState).isSceneTransition() 426 assertThat(transition).hasFromScene(SceneA) 427 assertThat(transition).hasToScene(SceneB) 428 assertThat(transition).hasProgress(0f) 429 430 // Change progress. 431 progress.send(0.4f) 432 assertThat(transition).hasProgress(0.4f) 433 434 // Close the channel with a CancellationException to cancel the transition. 435 progress.close(CancellationException()) 436 job.join() 437 assertThat(state.transitionState).isIdle() 438 assertThat(state.transitionState).hasCurrentScene(SceneA) 439 } 440 441 @Test 442 fun seekToScene_interrupted() = runMonotonicClockTest { 443 val state = MutableSceneTransitionLayoutState(SceneA) 444 val progress = Channel<Float>() 445 446 val job = 447 launch(start = CoroutineStart.UNDISPATCHED) { 448 state.seekToScene(SceneB, progress.consumeAsFlow()) 449 } 450 451 assertThat(state.transitionState).isSceneTransition() 452 453 // Start a new transition, interrupting the seek transition. 454 state.setTargetScene(SceneB, animationScope = this) 455 456 // The previous job is cancelled and does not infinitely collect the progress. 457 job.join() 458 } 459 460 @Test 461 fun replacedTransitionIsRemovedFromFinishedTransitions() = runTest { 462 val state = MutableSceneTransitionLayoutState(SceneA) 463 464 val aToB = 465 transition( 466 SceneA, 467 SceneB, 468 onFreezeAndAnimate = { 469 // Do nothing, so that this transition stays in the transitionStates list and we 470 // can finish() it manually later. 471 }, 472 ) 473 val replacingAToB = transition(SceneB, SceneC) 474 val replacingBToC = transition(SceneB, SceneC, replacedTransition = replacingAToB) 475 476 // Start A => B. 477 val aToBJob = state.startTransitionImmediately(animationScope = this, aToB) 478 479 // Start B => C and immediately finish it. It will be flagged as finished in 480 // STLState.finishedTransitions given that A => B is not finished yet. 481 val bToCJob = state.startTransitionImmediately(animationScope = this, replacingAToB) 482 replacingAToB.finish() 483 bToCJob.join() 484 485 // Start a new B => C that replaces the previously finished B => C. 486 val replacingBToCJob = 487 state.startTransitionImmediately(animationScope = this, replacingBToC) 488 489 // Finish A => B. 490 aToB.finish() 491 aToBJob.join() 492 493 // Finish the new B => C. 494 replacingBToC.finish() 495 replacingBToCJob.join() 496 497 assertThat(state.transitionState).isIdle() 498 assertThat(state.transitionState).hasCurrentScene(SceneC) 499 } 500 501 @Test 502 fun transition_progressTo() { 503 val transition = transition(from = SceneA, to = SceneB, progress = { 0.2f }) 504 assertThat(transition.progressTo(SceneB)).isEqualTo(0.2f) 505 assertThat(transition.progressTo(SceneA)).isEqualTo(1f - 0.2f) 506 assertThrows(IllegalArgumentException::class.java) { transition.progressTo(SceneC) } 507 } 508 509 @Test 510 fun transitionCanBeStartedOnlyOnce() = runTest { 511 val state = MutableSceneTransitionLayoutState(SceneA) 512 val transition = transition(from = SceneA, to = SceneB) 513 514 state.startTransitionImmediately(backgroundScope, transition) 515 assertThrows(IllegalStateException::class.java) { 516 runBlocking { state.startTransition(transition) } 517 } 518 } 519 520 @Test 521 fun transitionFinishedWhenScopeIsEmpty() = runTest { 522 val state = MutableSceneTransitionLayoutState(SceneA) 523 524 // Start a transition. 525 val transition = transition(from = SceneA, to = SceneB) 526 state.startTransitionImmediately(backgroundScope, transition) 527 assertThat(state.transitionState).isSceneTransition() 528 529 // Start a job in the transition scope. 530 val jobCompletable = CompletableDeferred<Unit>() 531 transition.coroutineScope.launch { jobCompletable.await() } 532 533 // Finish the transition (i.e. make its #run() method return). The transition should not be 534 // considered as finished yet given that there is a job still running in its scope. 535 transition.finish() 536 runCurrent() 537 assertThat(state.transitionState).isSceneTransition() 538 539 // Finish the job in the scope. Now the transition should be considered as finished. 540 jobCompletable.complete(Unit) 541 runCurrent() 542 assertThat(state.transitionState).isIdle() 543 } 544 545 @Test 546 fun transitionScopeIsCancelledWhenTransitionIsForceFinished() = runTest { 547 val state = MutableSceneTransitionLayoutState(SceneA) 548 549 // Start a transition. 550 val transition = transition(from = SceneA, to = SceneB) 551 state.startTransitionImmediately(backgroundScope, transition) 552 assertThat(state.transitionState).isSceneTransition() 553 554 // Start a job in the transition scope that never finishes. 555 val job = transition.coroutineScope.launch { awaitCancellation() } 556 557 // Force snap state to SceneB to force finish all current transitions. 558 state.snapToScene(SceneB) 559 assertThat(state.transitionState).isIdle() 560 assertThat(job.isCancelled).isTrue() 561 } 562 563 @Test 564 fun badTransitionSpecThrowsMeaningfulMessageWhenStartingTransition() { 565 val state = 566 MutableSceneTransitionLayoutState( 567 SceneA, 568 transitions { 569 // This transition definition is bad because they both match when transitioning 570 // from A to B. 571 from(SceneA) {} 572 to(SceneB) {} 573 }, 574 ) 575 576 val exception = 577 assertThrows(IllegalStateException::class.java) { 578 runBlocking { state.startTransition(transition(from = SceneA, to = SceneB)) } 579 } 580 581 assertThat(exception) 582 .hasMessageThat() 583 .isEqualTo( 584 "Found multiple transition specs for transition SceneKey(debugName=SceneA) => " + 585 "SceneKey(debugName=SceneB)" 586 ) 587 } 588 } 589