• 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 androidx.compose.animation.core.LinearEasing
20 import androidx.compose.animation.core.tween
21 import androidx.compose.foundation.layout.Box
22 import androidx.compose.foundation.layout.fillMaxSize
23 import androidx.compose.runtime.Composable
24 import androidx.compose.runtime.LaunchedEffect
25 import androidx.compose.runtime.rememberCoroutineScope
26 import androidx.compose.ui.Alignment
27 import androidx.compose.ui.Modifier
28 import androidx.compose.ui.geometry.Offset
29 import androidx.compose.ui.semantics.SemanticsNode
30 import androidx.compose.ui.test.SemanticsNodeInteraction
31 import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
32 import androidx.compose.ui.test.junit4.ComposeContentTestRule
33 import androidx.compose.ui.unit.Dp
34 import androidx.compose.ui.unit.lerp
35 import androidx.compose.ui.util.lerp
36 import kotlinx.coroutines.CoroutineScope
37 import platform.test.motion.MotionTestRule
38 import platform.test.motion.RecordedMotion
39 import platform.test.motion.compose.ComposeRecordingSpec
40 import platform.test.motion.compose.ComposeToolkit
41 import platform.test.motion.compose.MotionControl
42 import platform.test.motion.compose.feature
43 import platform.test.motion.compose.recordMotion
44 import platform.test.motion.golden.FeatureCapture
45 import platform.test.motion.golden.TimeSeriesCaptureScope
46 
47 @DslMarker annotation class TransitionTestDsl
48 
49 @TransitionTestDsl
50 interface TransitionTestBuilder {
51     /**
52      * Assert on the state of the layout before the transition starts.
53      *
54      * This should be called maximum once, before [at] or [after] is called.
55      */
56     fun before(builder: TransitionTestAssertionScope.() -> Unit)
57 
58     /**
59      * Assert on the state of the layout during the transition at [timestamp].
60      *
61      * This should be called after [before] is called and before [after] is called. Successive calls
62      * to [at] must be called with increasing [timestamp].
63      *
64      * Important: [timestamp] must be a multiple of 16 (the duration of a frame on the JVM/Android).
65      * There is no intermediary state between `t` and `t + 16` , so testing transitions outside of
66      * `t = 0`, `t = 16`, `t = 32`, etc does not make sense.
67      *
68      * @param builder the builder can run assertions and is passed the CoroutineScope such that the
69      *   test can start transitions at any desired point in time.
70      */
71     fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit)
72 
73     /**
74      * Run the same assertion for all frames of a transition.
75      *
76      * @param totalFrames needs to be the exact number of frames of the transition that is run,
77      *   otherwise the passed progress will be incorrect. That is the duration in ms divided by 16.
78      * @param builder is passed a progress Float which can be used to calculate values for the
79      *   specific frame. Or use [AutoTransitionTestAssertionScope.interpolate].
80      */
81     fun atAllFrames(totalFrames: Int, builder: AutoTransitionTestAssertionScope.(Float) -> Unit)
82 
83     /**
84      * Assert on the state of the layout after the transition finished.
85      *
86      * This should be called maximum once, after [before] or [at] is called.
87      */
88     fun after(builder: TransitionTestAssertionScope.() -> Unit)
89 }
90 
91 @TransitionTestDsl
92 interface TransitionTestAssertionScope : CoroutineScope {
93     /**
94      * Assert on [element].
95      *
96      * Note that presence/value assertions on the returned [SemanticsNodeInteraction] will fail if 0
97      * or more than 1 elements matched [element]. If you need to assert on a shared element that
98      * will be present multiple times in the layout during transitions, specify the [scene] in which
99      * you are matching.
100      */
onElementnull101     fun onElement(element: ElementKey, scene: SceneKey? = null): SemanticsNodeInteraction
102 }
103 
104 interface AutoTransitionTestAssertionScope : TransitionTestAssertionScope {
105 
106     /** Linear interpolate [from] and [to] with the current progress of the transition. */
107     fun <T> interpolate(from: T, to: T): T
108 }
109 
<lambda>null110 val Default4FrameLinearTransition: TransitionBuilder.() -> Unit = {
111     spec = tween(16 * 4, easing = LinearEasing)
112 }
113 
114 /**
115  * Test the transition between [fromSceneContent] and [toSceneContent] at different points in time.
116  *
117  * @sample com.android.compose.animation.scene.transformation.TranslateTest
118  */
ComposeContentTestRulenull119 fun ComposeContentTestRule.testTransition(
120     fromSceneContent: @Composable ContentScope.() -> Unit,
121     toSceneContent: @Composable ContentScope.() -> Unit,
122     transition: TransitionBuilder.() -> Unit = Default4FrameLinearTransition,
123     layoutModifier: Modifier = Modifier,
124     fromScene: SceneKey = TestScenes.SceneA,
125     toScene: SceneKey = TestScenes.SceneB,
126     changeState: CoroutineScope.(MutableSceneTransitionLayoutState) -> Unit = { state ->
127         state.setTargetScene(toScene, animationScope = this)
128     },
129     builder: TransitionTestBuilder.() -> Unit,
130 ) {
131     testTransition(
<lambda>null132         state = {
133             rememberMutableSceneTransitionLayoutState(
134                 fromScene,
135                 transitions { from(fromScene, to = toScene, builder = transition) },
136             )
137         },
138         changeState = changeState,
statenull139         transitionLayout = { state ->
140             SceneTransitionLayout(state, layoutModifier, implicitTestTags = true) {
141                 scene(fromScene, content = fromSceneContent)
142                 scene(toScene, content = toSceneContent)
143             }
144         },
145         builder = builder,
146     )
147 }
148 
149 /** Test the transition when showing [overlay] from [fromScene]. */
testShowOverlayTransitionnull150 fun ComposeContentTestRule.testShowOverlayTransition(
151     fromSceneContent: @Composable ContentScope.() -> Unit,
152     overlayContent: @Composable ContentScope.() -> Unit,
153     transition: TransitionBuilder.() -> Unit,
154     fromScene: SceneKey = TestScenes.SceneA,
155     overlay: OverlayKey = TestOverlays.OverlayA,
156     builder: TransitionTestBuilder.() -> Unit,
157 ) {
158     testTransition(
159         state = {
160             rememberMutableSceneTransitionLayoutState(
161                 fromScene,
162                 transitions = transitions { from(fromScene, overlay, builder = transition) },
163             )
164         },
165         transitionLayout = { state ->
166             SceneTransitionLayout(state, implicitTestTags = true) {
167                 scene(fromScene) { fromSceneContent() }
168                 overlay(overlay) { overlayContent() }
169             }
170         },
171         changeState = { state -> state.showOverlay(overlay, animationScope = this) },
172         builder = builder,
173     )
174 }
175 
176 /** Test the transition when hiding [overlay] to [toScene]. */
ComposeContentTestRulenull177 fun ComposeContentTestRule.testHideOverlayTransition(
178     toSceneContent: @Composable ContentScope.() -> Unit,
179     overlayContent: @Composable ContentScope.() -> Unit,
180     transition: TransitionBuilder.() -> Unit,
181     toScene: SceneKey = TestScenes.SceneA,
182     overlay: OverlayKey = TestOverlays.OverlayA,
183     builder: TransitionTestBuilder.() -> Unit,
184 ) {
185     testTransition(
186         state = {
187             rememberMutableSceneTransitionLayoutState(
188                 toScene,
189                 initialOverlays = setOf(overlay),
190                 transitions = transitions { from(overlay, toScene, builder = transition) },
191             )
192         },
193         transitionLayout = { state ->
194             SceneTransitionLayout(state, implicitTestTags = true) {
195                 scene(toScene) { toSceneContent() }
196                 overlay(overlay) { overlayContent() }
197             }
198         },
199         changeState = { state -> state.hideOverlay(overlay, animationScope = this) },
200         builder = builder,
201     )
202 }
203 
204 /** Test the transition when replace [from] to [to]. */
ComposeContentTestRulenull205 fun ComposeContentTestRule.testReplaceOverlayTransition(
206     fromContent: @Composable ContentScope.() -> Unit,
207     toContent: @Composable ContentScope.() -> Unit,
208     transition: TransitionBuilder.() -> Unit,
209     currentSceneContent: @Composable ContentScope.() -> Unit = { Box(Modifier.fillMaxSize()) },
210     fromAlignment: Alignment = Alignment.Center,
211     toAlignment: Alignment = Alignment.Center,
212     from: OverlayKey = TestOverlays.OverlayA,
213     to: OverlayKey = TestOverlays.OverlayB,
214     currentScene: SceneKey = TestScenes.SceneA,
215     builder: TransitionTestBuilder.() -> Unit,
216 ) {
217     testTransition(
<lambda>null218         state = {
219             rememberMutableSceneTransitionLayoutState(
220                 currentScene,
221                 initialOverlays = setOf(from),
222                 transitions = transitions { from(from, to, builder = transition) },
223             )
224         },
statenull225         transitionLayout = { state ->
226             SceneTransitionLayout(state, implicitTestTags = true) {
227                 scene(currentScene) { currentSceneContent() }
228                 overlay(from, alignment = fromAlignment) { fromContent() }
229                 overlay(to, alignment = toAlignment) { toContent() }
230             }
231         },
statenull232         changeState = { state -> state.replaceOverlay(from, to, animationScope = this) },
233         builder = builder,
234     )
235 }
236 
237 data class TransitionRecordingSpec(
238     val recordBefore: Boolean = true,
239     val recordAfter: Boolean = true,
240     val timeSeriesCapture: TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.() -> Unit,
241 )
242 
243 /** Captures the feature using [capture] on the [element]. */
TimeSeriesCaptureScopenull244 fun TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.featureOfElement(
245     element: ElementKey,
246     capture: FeatureCapture<SemanticsNode, *>,
247     name: String = "${element.debugName}_${capture.name}",
248 ) {
249     feature(isElement(element), capture, name)
250 }
251 
252 /** Records the transition between two scenes of [transitionLayout][SceneTransitionLayout]. */
MotionTestRulenull253 fun MotionTestRule<ComposeToolkit>.recordTransition(
254     fromSceneContent: @Composable ContentScope.() -> Unit,
255     toSceneContent: @Composable ContentScope.() -> Unit,
256     transition: TransitionBuilder.() -> Unit,
257     recordingSpec: TransitionRecordingSpec,
258     layoutModifier: Modifier = Modifier,
259     fromScene: SceneKey = TestScenes.SceneA,
260     toScene: SceneKey = TestScenes.SceneB,
261 ): RecordedMotion {
262     lateinit var state: MutableSceneTransitionLayoutState
263     return recordMotion(
264         content = { play ->
265             state =
266                 rememberMutableSceneTransitionLayoutState(
267                     fromScene,
268                     transitions { from(fromScene, to = toScene, builder = transition) },
269                 )
270             LaunchedEffect(play) {
271                 if (play) {
272                     state.setTargetScene(toScene, animationScope = this)
273                 }
274             }
275 
276             SceneTransitionLayout(state, layoutModifier, implicitTestTags = true) {
277                 scene(fromScene, content = fromSceneContent)
278                 scene(toScene, content = toSceneContent)
279             }
280         },
281         ComposeRecordingSpec(
282             MotionControl(delayRecording = { awaitCondition { state.isTransitioning() } }) {
283                 awaitCondition { !state.isTransitioning() }
284             },
285             recordBefore = recordingSpec.recordBefore,
286             recordAfter = recordingSpec.recordAfter,
287             timeSeriesCapture = recordingSpec.timeSeriesCapture,
288         ),
289     )
290 }
291 
292 /** Test the transition from [state] to [to]. */
ComposeContentTestRulenull293 fun ComposeContentTestRule.testTransition(
294     state: MutableSceneTransitionLayoutState,
295     to: SceneKey,
296     transitionLayout: @Composable (state: MutableSceneTransitionLayoutState) -> Unit,
297     builder: TransitionTestBuilder.() -> Unit,
298 ) {
299     val currentScene = state.transitionState.currentScene
300     check(currentScene != to) {
301         "The 'to' scene (${to.debugName}) should be different from the state current scene " +
302             "(${currentScene.debugName})"
303     }
304 
305     testTransition(
306         state = { state },
307         changeState = { state -> state.setTargetScene(to, animationScope = this) },
308         transitionLayout = transitionLayout,
309         builder = builder,
310     )
311 }
312 
testNestedTransitionnull313 fun ComposeContentTestRule.testNestedTransition(
314     states: List<MutableSceneTransitionLayoutState>,
315     changeState: CoroutineScope.(states: List<MutableSceneTransitionLayoutState>) -> Unit,
316     transitionLayout: @Composable (states: List<MutableSceneTransitionLayoutState>) -> Unit,
317     builder: TransitionTestBuilder.() -> Unit,
318 ) {
319     testTransition(
320         state = { states[0] },
321         changeState = { changeState(states) },
322         transitionLayout = { transitionLayout(states) },
323         builder = builder,
324     )
325 }
326 
327 /** Test the transition from [state] to [to]. */
ComposeContentTestRulenull328 private fun ComposeContentTestRule.testTransition(
329     state: @Composable () -> MutableSceneTransitionLayoutState,
330     changeState: CoroutineScope.(MutableSceneTransitionLayoutState) -> Unit,
331     transitionLayout: @Composable (state: MutableSceneTransitionLayoutState) -> Unit,
332     builder: TransitionTestBuilder.() -> Unit,
333 ) {
334     lateinit var coroutineScope: CoroutineScope
335     lateinit var layoutState: MutableSceneTransitionLayoutState
336     setContent {
337         layoutState = state()
338         coroutineScope = rememberCoroutineScope()
339         transitionLayout(layoutState)
340     }
341 
342     val assertionScope =
343         object : AutoTransitionTestAssertionScope, CoroutineScope by coroutineScope {
344 
345             var progress = 0f
346 
347             override fun onElement(
348                 element: ElementKey,
349                 scene: SceneKey?,
350             ): SemanticsNodeInteraction {
351                 return onNode(isElement(element, scene))
352             }
353 
354             override fun <T> interpolate(from: T, to: T): T {
355                 @Suppress("UNCHECKED_CAST")
356                 return when {
357                     from is Float && to is Float -> lerp(from, to, progress)
358                     from is Int && to is Int -> lerp(from, to, progress)
359                     from is Long && to is Long -> lerp(from, to, progress)
360                     from is Dp && to is Dp -> lerp(from, to, progress)
361                     from is Scale && to is Scale ->
362                         Scale(
363                             lerp(from.scaleX, to.scaleX, progress),
364                             lerp(from.scaleY, to.scaleY, progress),
365                             interpolate(from.pivot, to.pivot),
366                         )
367 
368                     from is Offset && to is Offset ->
369                         Offset(lerp(from.x, to.x, progress), lerp(from.y, to.y, progress))
370 
371                     else ->
372                         throw UnsupportedOperationException(
373                             "Interpolation not supported for this type"
374                         )
375                 }
376                     as T
377             }
378         }
379 
380     // Wait for the UI to be idle then test the before state.
381     waitForIdle()
382     val test = transitionTest(builder)
383     test.before(assertionScope)
384 
385     // Manually advance the clock to the start of the animation.
386     mainClock.autoAdvance = false
387 
388     // Change the current scene.
389     runOnUiThread { coroutineScope.changeState(layoutState) }
390     waitForIdle()
391     mainClock.advanceTimeByFrame()
392     waitForIdle()
393 
394     var currentTime = 0L
395     // Test the assertions at specific points in time.
396     test.timestamps.forEach { tsAssertion ->
397         if (tsAssertion.timestampDelta > 0L) {
398             mainClock.advanceTimeBy(tsAssertion.timestampDelta)
399             waitForIdle()
400             currentTime += tsAssertion.timestampDelta.toInt()
401         }
402 
403         assertionScope.progress = tsAssertion.progress
404         try {
405             tsAssertion.assertion(assertionScope, tsAssertion.progress)
406         } catch (assertionError: AssertionError) {
407             if (assertionScope.progress > 0) {
408                 throw AssertionError(
409                     "Transition assertion failed at ${currentTime}ms " +
410                         "at progress: ${assertionScope.progress}f",
411                     assertionError,
412                 )
413             }
414             throw assertionError
415         }
416     }
417 
418     // Go to the end state and test it.
419     mainClock.autoAdvance = true
420     waitForIdle()
421     test.after(assertionScope)
422 }
423 
transitionTestnull424 private fun transitionTest(builder: TransitionTestBuilder.() -> Unit): TransitionTest {
425     // Collect the assertion lambdas in [TransitionTest]. Note that the ordering is forced by the
426     // builder, e.g. `before {}` must be called before everything else, then `at {}` (in increasing
427     // order of timestamp), then `after {}`. That way the test code is run with the same order as it
428     // is written, to avoid confusion.
429 
430     val impl =
431         object : TransitionTestBuilder {
432                 var before: (TransitionTestAssertionScope.() -> Unit)? = null
433                 var after: (TransitionTestAssertionScope.() -> Unit)? = null
434                 val timestamps = mutableListOf<TimestampAssertion>()
435 
436                 private var currentTimestamp = 0L
437 
438                 override fun before(builder: TransitionTestAssertionScope.() -> Unit) {
439                     check(before == null) { "before {} must be called maximum once" }
440                     check(after == null) { "before {} must be called before after {}" }
441                     check(timestamps.isEmpty()) { "before {} must be called before at(...) {}" }
442 
443                     before = builder
444                 }
445 
446                 override fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit) {
447                     check(after == null) { "at(...) {} must be called before after {}" }
448                     check(timestamp >= currentTimestamp) {
449                         "at(...) must be called with timestamps in increasing order"
450                     }
451                     check(timestamp % 16 == 0L) {
452                         "timestamp must be a multiple of the frame time (16ms)"
453                     }
454 
455                     val delta = timestamp - currentTimestamp
456                     currentTimestamp = timestamp
457 
458                     timestamps.add(TimestampAssertion(delta, { builder() }, 0f))
459                 }
460 
461                 override fun atAllFrames(
462                     totalFrames: Int,
463                     builder: AutoTransitionTestAssertionScope.(Float) -> Unit,
464                 ) {
465                     check(after == null) { "atFrames(...) {} must be called before after {}" }
466                     check(currentTimestamp == 0L) {
467                         "atFrames(...) can't be called multiple times or after at(...)"
468                     }
469 
470                     for (frame in 0 until totalFrames) {
471                         val timestamp = frame * 16L
472                         val delta = timestamp - currentTimestamp
473                         val progress = frame.toFloat() / totalFrames
474                         currentTimestamp = timestamp
475                         timestamps.add(TimestampAssertion(delta, builder, progress))
476                     }
477                 }
478 
479                 override fun after(builder: TransitionTestAssertionScope.() -> Unit) {
480                     check(after == null) { "after {} must be called maximum once" }
481                     after = builder
482                 }
483             }
484             .apply(builder)
485 
486     return TransitionTest(
487         before = impl.before ?: {},
488         timestamps = impl.timestamps,
489         after = impl.after ?: {},
490     )
491 }
492 
493 private class TransitionTest(
494     val before: TransitionTestAssertionScope.() -> Unit,
495     val after: TransitionTestAssertionScope.() -> Unit,
496     val timestamps: List<TimestampAssertion>,
497 )
498 
499 private class TimestampAssertion(
500     val timestampDelta: Long,
501     val assertion: AutoTransitionTestAssertionScope.(Float) -> Unit,
502     val progress: Float,
503 )
504