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