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.runtime.Composable
20 import androidx.compose.runtime.LaunchedEffect
21 import androidx.compose.runtime.getValue
22 import androidx.compose.runtime.mutableStateOf
23 import androidx.compose.runtime.setValue
24 import androidx.compose.ui.Modifier
25 import androidx.compose.ui.semantics.SemanticsNode
26 import androidx.compose.ui.test.SemanticsNodeInteraction
27 import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
28 import androidx.compose.ui.test.junit4.ComposeContentTestRule
29 import platform.test.motion.MotionTestRule
30 import platform.test.motion.RecordedMotion
31 import platform.test.motion.compose.ComposeRecordingSpec
32 import platform.test.motion.compose.ComposeToolkit
33 import platform.test.motion.compose.MotionControl
34 import platform.test.motion.compose.feature
35 import platform.test.motion.compose.recordMotion
36 import platform.test.motion.golden.FeatureCapture
37 import platform.test.motion.golden.TimeSeriesCaptureScope
38 
39 @DslMarker annotation class TransitionTestDsl
40 
41 @TransitionTestDsl
42 interface TransitionTestBuilder {
43     /**
44      * Assert on the state of the layout before the transition starts.
45      *
46      * This should be called maximum once, before [at] or [after] is called.
47      */
48     fun before(builder: TransitionTestAssertionScope.() -> Unit)
49 
50     /**
51      * Assert on the state of the layout during the transition at [timestamp].
52      *
53      * This should be called after [before] is called and before [after] is called. Successive calls
54      * to [at] must be called with increasing [timestamp].
55      *
56      * Important: [timestamp] must be a multiple of 16 (the duration of a frame on the JVM/Android).
57      * There is no intermediary state between `t` and `t + 16` , so testing transitions outside of
58      * `t = 0`, `t = 16`, `t = 32`, etc does not make sense.
59      */
60     fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit)
61 
62     /**
63      * Assert on the state of the layout after the transition finished.
64      *
65      * This should be called maximum once, after [before] or [at] is called.
66      */
67     fun after(builder: TransitionTestAssertionScope.() -> Unit)
68 }
69 
70 @TransitionTestDsl
71 interface TransitionTestAssertionScope {
72     /**
73      * Assert on [element].
74      *
75      * Note that presence/value assertions on the returned [SemanticsNodeInteraction] will fail if 0
76      * or more than 1 elements matched [element]. If you need to assert on a shared element that
77      * will be present multiple times in the layout during transitions, specify the [scene] in which
78      * you are matching.
79      */
onElementnull80     fun onElement(element: ElementKey, scene: SceneKey? = null): SemanticsNodeInteraction
81 }
82 
83 /**
84  * Test the transition between [fromSceneContent] and [toSceneContent] at different points in time.
85  *
86  * @sample com.android.compose.animation.scene.transformation.TranslateTest
87  */
88 fun ComposeContentTestRule.testTransition(
89     fromSceneContent: @Composable SceneScope.() -> Unit,
90     toSceneContent: @Composable SceneScope.() -> Unit,
91     transition: TransitionBuilder.() -> Unit,
92     layoutModifier: Modifier = Modifier,
93     fromScene: SceneKey = TestScenes.SceneA,
94     toScene: SceneKey = TestScenes.SceneB,
95     builder: TransitionTestBuilder.() -> Unit,
96 ) {
97     testTransition(
98         from = fromScene,
99         to = toScene,
100         transitionLayout = { currentScene, onChangeScene ->
101             SceneTransitionLayout(
102                 currentScene,
103                 onChangeScene,
104                 transitions { from(fromScene, to = toScene, builder = transition) },
105                 layoutModifier,
106             ) {
107                 scene(fromScene, content = fromSceneContent)
108                 scene(toScene, content = toSceneContent)
109             }
110         },
111         builder,
112     )
113 }
114 
115 data class TransitionRecordingSpec(
116     val recordBefore: Boolean = true,
117     val recordAfter: Boolean = true,
118     val timeSeriesCapture: TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.() -> Unit
119 )
120 
121 /** Captures the feature using [capture] on the [element]. */
TimeSeriesCaptureScopenull122 fun TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.featureOfElement(
123     element: ElementKey,
124     capture: FeatureCapture<SemanticsNode, *>,
125     name: String = "${element.debugName}_${capture.name}"
126 ) {
127     feature(isElement(element), capture, name)
128 }
129 
130 /** Records the transition between two scenes of [transitionLayout][SceneTransitionLayout]. */
MotionTestRulenull131 fun MotionTestRule<ComposeToolkit>.recordTransition(
132     fromSceneContent: @Composable SceneScope.() -> Unit,
133     toSceneContent: @Composable SceneScope.() -> Unit,
134     transition: TransitionBuilder.() -> Unit,
135     recordingSpec: TransitionRecordingSpec,
136     layoutModifier: Modifier = Modifier,
137     fromScene: SceneKey = TestScenes.SceneA,
138     toScene: SceneKey = TestScenes.SceneB,
139 ): RecordedMotion {
140     val state =
141         toolkit.composeContentTestRule.runOnUiThread {
142             MutableSceneTransitionLayoutState(
143                 fromScene,
144                 transitions { from(fromScene, to = toScene, builder = transition) }
145             )
146         }
147 
148     return recordMotion(
149         content = { play ->
150             LaunchedEffect(play) {
151                 if (play) {
152                     state.setTargetScene(toScene, coroutineScope = this)
153                 }
154             }
155 
156             SceneTransitionLayout(
157                 state,
158                 layoutModifier,
159             ) {
160                 scene(fromScene, content = fromSceneContent)
161                 scene(toScene, content = toSceneContent)
162             }
163         },
164         ComposeRecordingSpec(
165             MotionControl(delayRecording = { awaitCondition { state.isTransitioning() } }) {
166                 awaitCondition { !state.isTransitioning() }
167             },
168             recordBefore = recordingSpec.recordBefore,
169             recordAfter = recordingSpec.recordAfter,
170             timeSeriesCapture = recordingSpec.timeSeriesCapture
171         )
172     )
173 }
174 
175 /**
176  * Test the transition between two scenes of [transitionLayout][SceneTransitionLayout] at different
177  * points in time.
178  */
ComposeContentTestRulenull179 fun ComposeContentTestRule.testTransition(
180     from: SceneKey,
181     to: SceneKey,
182     transitionLayout:
183         @Composable
184         (
185             currentScene: SceneKey,
186             onChangeScene: (SceneKey) -> Unit,
187         ) -> Unit,
188     builder: TransitionTestBuilder.() -> Unit,
189 ) {
190     val test = transitionTest(builder)
191     val assertionScope =
192         object : TransitionTestAssertionScope {
193             override fun onElement(
194                 element: ElementKey,
195                 scene: SceneKey?
196             ): SemanticsNodeInteraction {
197                 return onNode(isElement(element, scene))
198             }
199         }
200 
201     var currentScene by mutableStateOf(from)
202     setContent { transitionLayout(currentScene, { currentScene = it }) }
203 
204     // Wait for the UI to be idle then test the before state.
205     waitForIdle()
206     test.before(assertionScope)
207 
208     // Manually advance the clock to the start of the animation.
209     mainClock.autoAdvance = false
210 
211     // Change the current scene.
212     currentScene = to
213 
214     // Advance by a frame to trigger recomposition, which will start the transition (i.e. it will
215     // change the transitionState to be a Transition) in a LaunchedEffect.
216     mainClock.advanceTimeByFrame()
217 
218     // Advance by another frame so that the animator we started gets its initial value and clock
219     // starting time. We are now at progress = 0f.
220     mainClock.advanceTimeByFrame()
221     waitForIdle()
222 
223     // Test the assertions at specific points in time.
224     test.timestamps.forEach { tsAssertion ->
225         if (tsAssertion.timestampDelta > 0L) {
226             mainClock.advanceTimeBy(tsAssertion.timestampDelta)
227             waitForIdle()
228         }
229 
230         tsAssertion.assertion(assertionScope)
231     }
232 
233     // Go to the end state and test it.
234     mainClock.autoAdvance = true
235     waitForIdle()
236     test.after(assertionScope)
237 }
238 
transitionTestnull239 private fun transitionTest(builder: TransitionTestBuilder.() -> Unit): TransitionTest {
240     // Collect the assertion lambdas in [TransitionTest]. Note that the ordering is forced by the
241     // builder, e.g. `before {}` must be called before everything else, then `at {}` (in increasing
242     // order of timestamp), then `after {}`. That way the test code is run with the same order as it
243     // is written, to avoid confusion.
244 
245     val impl =
246         object : TransitionTestBuilder {
247                 var before: (TransitionTestAssertionScope.() -> Unit)? = null
248                 var after: (TransitionTestAssertionScope.() -> Unit)? = null
249                 val timestamps = mutableListOf<TimestampAssertion>()
250 
251                 private var currentTimestamp = 0L
252 
253                 override fun before(builder: TransitionTestAssertionScope.() -> Unit) {
254                     check(before == null) { "before {} must be called maximum once" }
255                     check(after == null) { "before {} must be called before after {}" }
256                     check(timestamps.isEmpty()) { "before {} must be called before at(...) {}" }
257 
258                     before = builder
259                 }
260 
261                 override fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit) {
262                     check(after == null) { "at(...) {} must be called before after {}" }
263                     check(timestamp >= currentTimestamp) {
264                         "at(...) must be called with timestamps in increasing order"
265                     }
266                     check(timestamp % 16 == 0L) {
267                         "timestamp must be a multiple of the frame time (16ms)"
268                     }
269 
270                     val delta = timestamp - currentTimestamp
271                     currentTimestamp = timestamp
272 
273                     timestamps.add(TimestampAssertion(delta, builder))
274                 }
275 
276                 override fun after(builder: TransitionTestAssertionScope.() -> Unit) {
277                     check(after == null) { "after {} must be called maximum once" }
278                     after = builder
279                 }
280             }
281             .apply(builder)
282 
283     return TransitionTest(
284         before = impl.before ?: {},
285         timestamps = impl.timestamps,
286         after = impl.after ?: {},
287     )
288 }
289 
290 private class TransitionTest(
291     val before: TransitionTestAssertionScope.() -> Unit,
292     val after: TransitionTestAssertionScope.() -> Unit,
293     val timestamps: List<TimestampAssertion>,
294 )
295 
296 private class TimestampAssertion(
297     val timestampDelta: Long,
298     val assertion: TransitionTestAssertionScope.() -> Unit,
299 )
300