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