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