• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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