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.ui.test.junit4.createComposeRule 21 import androidx.test.ext.junit.runners.AndroidJUnit4 22 import com.android.compose.animation.scene.TestScenes.SceneA 23 import com.android.compose.animation.scene.TestScenes.SceneB 24 import com.android.compose.animation.scene.TestScenes.SceneC 25 import com.android.compose.animation.scene.content.state.TransitionState 26 import com.android.compose.animation.scene.subjects.assertThat 27 import com.android.compose.test.TestSceneTransition 28 import com.android.compose.test.runMonotonicClockTest 29 import com.android.compose.test.transition 30 import com.google.common.truth.Truth.assertThat 31 import kotlinx.coroutines.CompletableDeferred 32 import kotlinx.coroutines.CoroutineStart 33 import kotlinx.coroutines.awaitCancellation 34 import kotlinx.coroutines.cancelAndJoin 35 import kotlinx.coroutines.launch 36 import kotlinx.coroutines.runBlocking 37 import kotlinx.coroutines.test.runCurrent 38 import kotlinx.coroutines.test.runTest 39 import org.junit.Assert.assertThrows 40 import org.junit.Rule 41 import org.junit.Test 42 import org.junit.runner.RunWith 43 44 @RunWith(AndroidJUnit4::class) 45 class SceneTransitionLayoutStateTest { 46 @get:Rule val rule = createComposeRule() 47 48 @Test 49 fun isTransitioningTo_idle() { 50 val state = MutableSceneTransitionLayoutStateForTests(SceneA, SceneTransitions.Empty) 51 52 assertThat(state.isTransitioning()).isFalse() 53 assertThat(state.isTransitioning(from = SceneA)).isFalse() 54 assertThat(state.isTransitioning(to = SceneB)).isFalse() 55 assertThat(state.isTransitioning(from = SceneA, to = SceneB)).isFalse() 56 } 57 58 @Test 59 fun isTransitioningTo_transition() = runTest { 60 val state = MutableSceneTransitionLayoutStateForTests(SceneA, SceneTransitions.Empty) 61 state.startTransitionImmediately( 62 animationScope = backgroundScope, 63 transition(from = SceneA, to = SceneB), 64 ) 65 66 assertThat(state.isTransitioning()).isTrue() 67 assertThat(state.isTransitioning(from = SceneA)).isTrue() 68 assertThat(state.isTransitioning(from = SceneB)).isFalse() 69 assertThat(state.isTransitioning(to = SceneB)).isTrue() 70 assertThat(state.isTransitioning(to = SceneA)).isFalse() 71 assertThat(state.isTransitioning(from = SceneA, to = SceneB)).isTrue() 72 } 73 74 @Test 75 fun setTargetScene_idleToSameScene() = runMonotonicClockTest { 76 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 77 assertThat(state.setTargetScene(SceneA, animationScope = this)).isNull() 78 } 79 80 @Test 81 fun setTargetScene_idleToDifferentScene() = runMonotonicClockTest { 82 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 83 val (transition, job) = checkNotNull(state.setTargetScene(SceneB, animationScope = this)) 84 assertThat(state.transitionState).isEqualTo(transition) 85 86 job.join() 87 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB)) 88 } 89 90 @Test 91 fun setTargetScene_transitionToSameScene() = runMonotonicClockTest { 92 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 93 94 val (_, job) = checkNotNull(state.setTargetScene(SceneB, animationScope = this)) 95 assertThat(state.setTargetScene(SceneB, animationScope = this)).isNull() 96 97 job.join() 98 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB)) 99 } 100 101 @Test 102 fun setTargetScene_transitionToDifferentScene() = runMonotonicClockTest { 103 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 104 105 assertThat(state.setTargetScene(SceneB, animationScope = this)).isNotNull() 106 val (_, job) = checkNotNull(state.setTargetScene(SceneC, animationScope = this)) 107 108 job.join() 109 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneC)) 110 } 111 112 @Test 113 fun setTargetScene_coroutineScopeCancelled() = runMonotonicClockTest { 114 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 115 116 lateinit var transition: TransitionState.Transition 117 val job = 118 launch(start = CoroutineStart.UNDISPATCHED) { 119 transition = checkNotNull(state.setTargetScene(SceneB, animationScope = this)).first 120 } 121 assertThat(state.transitionState).isEqualTo(transition) 122 123 // Cancelling the scope/job still sets the state to Idle(targetScene). 124 job.cancelAndJoin() 125 assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB)) 126 } 127 128 @Test 129 fun setTargetScene_withTransitionKey() = runMonotonicClockTest { 130 val transitionkey = TransitionKey(debugName = "foo") 131 val state = 132 MutableSceneTransitionLayoutStateForTests( 133 SceneA, 134 transitions = 135 transitions { 136 from(SceneA, to = SceneB) { fade(TestElements.Foo) } 137 from(SceneA, to = SceneB, key = transitionkey) { 138 fade(TestElements.Foo) 139 fade(TestElements.Bar) 140 } 141 }, 142 ) 143 144 // Default transition from A to B. 145 assertThat(state.setTargetScene(SceneB, animationScope = this)).isNotNull() 146 assertThat(state.currentTransition?.transformationSpec?.transformationMatchers).hasSize(1) 147 148 // Go back to A. 149 state.setTargetScene(SceneA, animationScope = this) 150 testScheduler.advanceUntilIdle() 151 assertThat(state.transitionState).isIdle() 152 assertThat(state.transitionState).hasCurrentScene(SceneA) 153 154 // Specific transition from A to B. 155 assertThat( 156 state.setTargetScene(SceneB, animationScope = this, transitionKey = transitionkey) 157 ) 158 .isNotNull() 159 assertThat(state.currentTransition?.transformationSpec?.transformationMatchers).hasSize(2) 160 } 161 162 @Test 163 fun multipleTransitions() = runTest { 164 val frozenTransitions = mutableSetOf<TestSceneTransition>() 165 fun onFreezeAndAnimate(transition: TestSceneTransition): () -> Unit { 166 // Instead of letting the transition finish when it is frozen, we put the transition in 167 // the frozenTransitions set so that we can verify that freezeAndAnimateToCurrentState() 168 // is called when expected and then we call finish() ourselves to finish the 169 // transitions. 170 frozenTransitions.add(transition) 171 172 return { /* do nothing */ } 173 } 174 175 val state = MutableSceneTransitionLayoutStateForTests(SceneA, EmptyTestTransitions) 176 val aToB = transition(SceneA, SceneB, onFreezeAndAnimate = ::onFreezeAndAnimate) 177 val bToC = transition(SceneB, SceneC, onFreezeAndAnimate = ::onFreezeAndAnimate) 178 val cToA = transition(SceneC, SceneA, onFreezeAndAnimate = ::onFreezeAndAnimate) 179 180 // Starting state. 181 assertThat(frozenTransitions).isEmpty() 182 assertThat(state.currentTransitions).isEmpty() 183 184 // A => B. 185 val aToBJob = state.startTransitionImmediately(animationScope = backgroundScope, aToB) 186 assertThat(frozenTransitions).isEmpty() 187 assertThat(state.finishedTransitions).isEmpty() 188 assertThat(state.currentTransitions).containsExactly(aToB).inOrder() 189 190 // B => C. This should automatically call freezeAndAnimateToCurrentState() on aToB. 191 val bToCJob = state.startTransitionImmediately(animationScope = backgroundScope, bToC) 192 assertThat(frozenTransitions).containsExactly(aToB) 193 assertThat(state.finishedTransitions).isEmpty() 194 assertThat(state.currentTransitions).containsExactly(aToB, bToC).inOrder() 195 196 // C => A. This should automatically call freezeAndAnimateToCurrentState() on bToC. 197 val cToAJob = state.startTransitionImmediately(animationScope = backgroundScope, cToA) 198 assertThat(frozenTransitions).containsExactly(aToB, bToC) 199 assertThat(state.finishedTransitions).isEmpty() 200 assertThat(state.currentTransitions).containsExactly(aToB, bToC, cToA).inOrder() 201 202 // Mark aToB and bToC as finished. The list of current transitions does not change because 203 // cToA is still running. 204 aToB.finish() 205 aToBJob.join() 206 assertThat(state.finishedTransitions).containsExactly(aToB) 207 assertThat(state.currentTransitions).containsExactly(aToB, bToC, cToA).inOrder() 208 209 bToC.finish() 210 bToCJob.join() 211 assertThat(state.finishedTransitions).containsExactly(aToB, bToC) 212 assertThat(state.currentTransitions).containsExactly(aToB, bToC, cToA).inOrder() 213 214 // Mark cToA as finished. This should clear all transitions and settle to idle. 215 cToA.finish() 216 cToAJob.join() 217 assertThat(state.finishedTransitions).isEmpty() 218 assertThat(state.currentTransitions).isEmpty() 219 assertThat(state.transitionState).isIdle() 220 assertThat(state.transitionState).hasCurrentScene(SceneA) 221 } 222 223 @Test 224 fun tooManyTransitionsLogsWtfAndClearsTransitions() = runTest { 225 val state = MutableSceneTransitionLayoutStateForTests(SceneA, EmptyTestTransitions) 226 227 fun startTransition() { 228 val transition = 229 transition(SceneA, SceneB, onFreezeAndAnimate = { launch { /* do nothing */ } }) 230 state.startTransitionImmediately(animationScope = backgroundScope, transition) 231 } 232 233 var hasLoggedWtf = false 234 val originalHandler = Log.setWtfHandler { _, _, _ -> hasLoggedWtf = true } 235 try { 236 repeat(100) { startTransition() } 237 assertThat(hasLoggedWtf).isFalse() 238 assertThat(state.currentTransitions).hasSize(100) 239 240 startTransition() 241 assertThat(hasLoggedWtf).isTrue() 242 assertThat(state.currentTransitions).hasSize(1) 243 } finally { 244 Log.setWtfHandler(originalHandler) 245 } 246 } 247 248 @Test 249 fun snapToScene() = runMonotonicClockTest { 250 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 251 252 // Transition to B. 253 state.setTargetScene(SceneB, animationScope = this) 254 val transition = assertThat(state.transitionState).isSceneTransition() 255 assertThat(transition).hasCurrentScene(SceneB) 256 257 // Snap to C. 258 state.snapTo(SceneC) 259 assertThat(state.transitionState).isIdle() 260 assertThat(state.transitionState).hasCurrentScene(SceneC) 261 } 262 263 @Test 264 fun snapToScene_freezesCurrentTransition() = runMonotonicClockTest { 265 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 266 267 // Start a transition that is never finished. We don't use backgroundScope on purpose so 268 // that this test would fail if the transition was not frozen when snapping. 269 state.startTransitionImmediately(animationScope = this, transition(SceneA, SceneB)) 270 val transition = assertThat(state.transitionState).isSceneTransition() 271 assertThat(transition).hasFromScene(SceneA) 272 assertThat(transition).hasToScene(SceneB) 273 274 // Snap to C. 275 state.snapTo(SceneC) 276 assertThat(state.transitionState).isIdle() 277 assertThat(state.transitionState).hasCurrentScene(SceneC) 278 } 279 280 @Test 281 fun replacedTransitionIsRemovedFromFinishedTransitions() = runTest { 282 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 283 284 val aToB = 285 transition( 286 SceneA, 287 SceneB, 288 onFreezeAndAnimate = { 289 // Do nothing, so that this transition stays in the transitionStates list and we 290 // can finish() it manually later. 291 }, 292 ) 293 val replacingAToB = transition(SceneB, SceneC) 294 val replacingBToC = transition(SceneB, SceneC, replacedTransition = replacingAToB) 295 296 // Start A => B. 297 val aToBJob = state.startTransitionImmediately(animationScope = this, aToB) 298 299 // Start B => C and immediately finish it. It will be flagged as finished in 300 // STLState.finishedTransitions given that A => B is not finished yet. 301 val bToCJob = state.startTransitionImmediately(animationScope = this, replacingAToB) 302 replacingAToB.finish() 303 bToCJob.join() 304 305 // Start a new B => C that replaces the previously finished B => C. 306 val replacingBToCJob = 307 state.startTransitionImmediately(animationScope = this, replacingBToC) 308 309 // Finish A => B. 310 aToB.finish() 311 aToBJob.join() 312 313 // Finish the new B => C. 314 replacingBToC.finish() 315 replacingBToCJob.join() 316 317 assertThat(state.transitionState).isIdle() 318 assertThat(state.transitionState).hasCurrentScene(SceneC) 319 } 320 321 @Test 322 fun transition_progressTo() { 323 val transition = transition(from = SceneA, to = SceneB, progress = { 0.2f }) 324 assertThat(transition.progressTo(SceneB)).isEqualTo(0.2f) 325 assertThat(transition.progressTo(SceneA)).isEqualTo(1f - 0.2f) 326 assertThrows(IllegalArgumentException::class.java) { transition.progressTo(SceneC) } 327 } 328 329 @Test 330 fun transitionCanBeStartedOnlyOnce() = runTest { 331 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 332 val transition = transition(from = SceneA, to = SceneB) 333 334 state.startTransitionImmediately(backgroundScope, transition) 335 assertThrows(IllegalStateException::class.java) { 336 runBlocking { state.startTransition(transition) } 337 } 338 } 339 340 @Test 341 fun transitionFinishedWhenScopeIsEmpty() = runTest { 342 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 343 344 // Start a transition. 345 val transition = transition(from = SceneA, to = SceneB) 346 state.startTransitionImmediately(backgroundScope, transition) 347 assertThat(state.transitionState).isSceneTransition() 348 349 // Start a job in the transition scope. 350 val jobCompletable = CompletableDeferred<Unit>() 351 transition.coroutineScope.launch { jobCompletable.await() } 352 353 // Finish the transition (i.e. make its #run() method return). The transition should not be 354 // considered as finished yet given that there is a job still running in its scope. 355 transition.finish() 356 runCurrent() 357 assertThat(state.transitionState).isSceneTransition() 358 359 // Finish the job in the scope. Now the transition should be considered as finished. 360 jobCompletable.complete(Unit) 361 runCurrent() 362 assertThat(state.transitionState).isIdle() 363 } 364 365 @Test 366 fun transitionScopeIsCancelledWhenTransitionIsForceFinished() = runTest { 367 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 368 369 // Start a transition. 370 val transition = transition(from = SceneA, to = SceneB) 371 state.startTransitionImmediately(backgroundScope, transition) 372 assertThat(state.transitionState).isSceneTransition() 373 374 // Start a job in the transition scope that never finishes. 375 val job = transition.coroutineScope.launch { awaitCancellation() } 376 377 // Force snap state to SceneB to force finish all current transitions. 378 state.snapTo(SceneB) 379 assertThat(state.transitionState).isIdle() 380 assertThat(job.isCancelled).isTrue() 381 } 382 383 @Test 384 fun badTransitionSpecThrowsMeaningfulMessageWhenStartingTransition() { 385 val state = 386 MutableSceneTransitionLayoutStateForTests( 387 SceneA, 388 transitions { 389 // This transition definition is bad because they both match when transitioning 390 // from A to B. 391 from(SceneA) {} 392 to(SceneB) {} 393 }, 394 ) 395 396 val exception = 397 assertThrows(IllegalStateException::class.java) { 398 runBlocking { state.startTransition(transition(from = SceneA, to = SceneB)) } 399 } 400 401 assertThat(exception) 402 .hasMessageThat() 403 .isEqualTo( 404 "Found multiple transition specs for transition SceneKey(debugName=SceneA) => " + 405 "SceneKey(debugName=SceneB)" 406 ) 407 } 408 409 @Test 410 fun snapToScene_multipleTransitions() = runMonotonicClockTest { 411 val state = MutableSceneTransitionLayoutStateForTests(SceneA) 412 state.startTransitionImmediately(this, transition(SceneA, SceneB)) 413 state.startTransitionImmediately(this, transition(SceneB, SceneC)) 414 state.snapTo(SceneC) 415 416 assertThat(state.transitionState).isIdle() 417 assertThat(state.transitionState).hasCurrentScene(SceneC) 418 } 419 420 @Test 421 fun trackTransitionCujs() = runTest { 422 val started = mutableSetOf<TransitionState.Transition>() 423 val finished = mutableSetOf<TransitionState.Transition>() 424 val cujWhenStarting = mutableMapOf<TransitionState.Transition, Int?>() 425 val state = 426 MutableSceneTransitionLayoutStateForTests( 427 SceneA, 428 transitions { 429 // A <=> B. 430 from(SceneA, to = SceneB, cuj = 1) 431 432 // A <=> C. 433 from(SceneA, to = SceneC, cuj = 2) 434 from(SceneC, to = SceneA, cuj = 3) 435 }, 436 onTransitionStart = { transition -> 437 started.add(transition) 438 cujWhenStarting[transition] = transition.cuj 439 }, 440 onTransitionEnd = { finished.add(it) }, 441 ) 442 443 val aToB = transition(SceneA, SceneB) 444 val bToA = transition(SceneB, SceneA) 445 val aToC = transition(SceneA, SceneC) 446 val cToA = transition(SceneC, SceneA) 447 448 val animationScope = this 449 state.startTransitionImmediately(animationScope, aToB) 450 assertThat(started).containsExactly(aToB) 451 assertThat(finished).isEmpty() 452 453 state.startTransitionImmediately(animationScope, bToA) 454 assertThat(started).containsExactly(aToB, bToA) 455 assertThat(finished).isEmpty() 456 457 aToB.finish() 458 runCurrent() 459 assertThat(finished).containsExactly(aToB) 460 461 state.startTransitionImmediately(animationScope, aToC) 462 assertThat(started).containsExactly(aToB, bToA, aToC) 463 assertThat(finished).containsExactly(aToB) 464 465 state.startTransitionImmediately(animationScope, cToA) 466 assertThat(started).containsExactly(aToB, bToA, aToC, cToA) 467 assertThat(finished).containsExactly(aToB) 468 469 bToA.finish() 470 aToC.finish() 471 cToA.finish() 472 runCurrent() 473 assertThat(started).containsExactly(aToB, bToA, aToC, cToA) 474 assertThat(finished).containsExactly(aToB, bToA, aToC, cToA) 475 476 assertThat(cujWhenStarting[aToB]).isEqualTo(1) 477 assertThat(cujWhenStarting[bToA]).isEqualTo(1) 478 assertThat(cujWhenStarting[aToC]).isEqualTo(2) 479 assertThat(cujWhenStarting[cToA]).isEqualTo(3) 480 } 481 } 482