• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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 platform.test.motion.compose
18 
19 import androidx.compose.animation.animateContentSize
20 import androidx.compose.animation.core.Animatable
21 import androidx.compose.animation.core.animateDpAsState
22 import androidx.compose.animation.core.animateFloatAsState
23 import androidx.compose.animation.core.tween
24 import androidx.compose.foundation.background
25 import androidx.compose.foundation.clickable
26 import androidx.compose.foundation.gestures.detectDragGestures
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.foundation.layout.fillMaxHeight
29 import androidx.compose.foundation.layout.height
30 import androidx.compose.foundation.layout.offset
31 import androidx.compose.foundation.layout.size
32 import androidx.compose.foundation.layout.width
33 import androidx.compose.material3.Text
34 import androidx.compose.runtime.Composable
35 import androidx.compose.runtime.LaunchedEffect
36 import androidx.compose.runtime.getValue
37 import androidx.compose.runtime.mutableStateOf
38 import androidx.compose.runtime.remember
39 import androidx.compose.runtime.setValue
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.draw.drawBehind
42 import androidx.compose.ui.geometry.Offset
43 import androidx.compose.ui.geometry.isSpecified
44 import androidx.compose.ui.geometry.lerp
45 import androidx.compose.ui.graphics.Color
46 import androidx.compose.ui.graphics.graphicsLayer
47 import androidx.compose.ui.input.pointer.pointerInput
48 import androidx.compose.ui.platform.testTag
49 import androidx.compose.ui.test.click
50 import androidx.compose.ui.test.hasTestTag
51 import androidx.compose.ui.test.onNodeWithTag
52 import androidx.compose.ui.test.performTouchInput
53 import androidx.compose.ui.test.swipeDown
54 import androidx.compose.ui.unit.dp
55 import androidx.test.ext.junit.runners.AndroidJUnit4
56 import com.google.common.truth.IterableSubject
57 import com.google.common.truth.Truth.assertThat
58 import kotlin.time.Duration.Companion.milliseconds
59 import kotlinx.coroutines.launch
60 import org.junit.Rule
61 import org.junit.Test
62 import org.junit.runner.RunWith
63 import platform.test.motion.MotionTestRule
64 import platform.test.motion.compose.DataPointTypes.offset
65 import platform.test.motion.compose.values.MotionTestValueKey
66 import platform.test.motion.compose.values.MotionTestValues
67 import platform.test.motion.compose.values.motionTestValues
68 import platform.test.motion.golden.DataPointTypes
69 import platform.test.motion.golden.NotFoundDataPoint
70 import platform.test.motion.golden.ValueDataPoint
71 import platform.test.motion.testing.createGoldenPathManager
72 
73 @RunWith(AndroidJUnit4::class)
74 class ComposeToolkitTest {
75     private val pathManager =
76         createGoldenPathManager("platform_testing/libraries/motion/compose/tests/goldens")
77     @get:Rule val motionRule = createFixedConfigurationComposeMotionTestRule(pathManager)
78 
79     @Test
80     fun recordMotion_capturePosition() =
81         motionRule.runTest {
82             var completed = false
83 
84             val motion =
85                 recordMotion(
86                     content = { play ->
87                         val offset by
88                             animateDpAsState(if (play) 90.dp else 0.dp) { completed = true }
89                         Box(
90                             modifier =
91                                 Modifier.offset(x = offset)
92                                     .testTag("foo")
93                                     .size(10.dp)
94                                     .background(Color.Red)
95                         )
96                     },
97                     ComposeRecordingSpec.until({ completed }) {
98                         feature(hasTestTag("foo"), ComposeFeatureCaptures.positionInRoot)
99                     },
100                 )
101 
102             assertThat(motion).timeSeriesMatchesGolden()
103         }
104 
105     @Test
106     fun recordMotion_captureSize() =
107         motionRule.runTest {
108             var completed = false
109 
110             val motion =
111                 recordMotion(
112                     content = { play ->
113                         Box(
114                             modifier =
115                                 Modifier.testTag("foo")
116                                     .animateContentSize { _, _ -> completed = true }
117                                     .width(if (play) 90.dp else 10.dp)
118                                     .height(10.dp)
119                                     .background(Color.Red)
120                         )
121                     },
122                     ComposeRecordingSpec.until({ completed }) {
123                         feature(hasTestTag("foo"), ComposeFeatureCaptures.dpSize)
124                     },
125                 )
126 
127             assertThat(motion).timeSeriesMatchesGolden()
128         }
129 
130     @Test
131     fun recordMotion_captureAlpha() =
132         motionRule.runTest {
133             var completed = false
134 
135             val motion =
136                 recordMotion(
137                     content = { play ->
138                         val opacity by
139                             animateFloatAsState(if (play) 1f else 0f) { completed = true }
140                         Box(
141                             modifier =
142                                 Modifier.graphicsLayer { alpha = opacity }
143                                     .size(10.dp)
144                                     .fillMaxHeight()
145                                     .background(Color.Red)
146                                     .testTag("BoxOfInterest")
147                                     .motionTestValues { opacity exportAs MotionTestValues.alpha }
148                         )
149                     },
150                     ComposeRecordingSpec.until({ completed }) {
151                         feature(hasTestTag("BoxOfInterest"), ComposeFeatureCaptures.alpha)
152                     },
153                 )
154 
155             assertThat(motion).timeSeriesMatchesGolden()
156         }
157 
158     @Test
159     fun recordMotion_captureCrossfade() =
160         motionRule.runTest {
161             var completed = false
162 
163             val motion =
164                 recordMotion(
165                     content = { play ->
166                         val opacity by
167                             animateFloatAsState(if (play) 1f else 0f) { completed = true }
168 
169                         Box(
170                             modifier =
171                                 Modifier.graphicsLayer { alpha = opacity }
172                                     .size(10.dp)
173                                     .fillMaxHeight()
174                                     .background(Color.Red)
175                                     .testTag("bar")
176                                     .motionTestValues { opacity exportAs MotionTestValues.alpha }
177                         )
178                         Box(
179                             modifier =
180                                 Modifier.graphicsLayer { alpha = 1 - opacity }
181                                     .size(10.dp)
182                                     .fillMaxHeight()
183                                     .background(Color.Blue)
184                                     .testTag("foo")
185                                     .motionTestValues {
186                                         (1f - opacity) exportAs MotionTestValues.alpha
187                                     }
188                         )
189                     },
190                     ComposeRecordingSpec.until({ completed }) {
191                         feature(hasTestTag("bar"), ComposeFeatureCaptures.alpha, name = "bar_alpha")
192                         feature(hasTestTag("foo"), ComposeFeatureCaptures.alpha, name = "foo_alpha")
193                     },
194                 )
195 
196             assertThat(motion).timeSeriesMatchesGolden()
197         }
198 
199     @Test
200     fun recordMotion_motionControl_performTap() =
201         motionRule.runTest {
202             val motion =
203                 recordMotion(
204                     content = {
205                         var clickCount by remember { mutableStateOf(0) }
206                         val animatedSize by animateDpAsState(targetValue = 20.dp * (clickCount + 1))
207                         Box(
208                             modifier =
209                                 Modifier.clickable { clickCount++ }
210                                     .testTag("foo")
211                                     .width(animatedSize)
212                                     .height(20.dp)
213                         )
214                     },
215                     ComposeRecordingSpec(
216                         recording = {
217                             awaitFrames(1)
218                             onNodeWithTag("foo").performTouchInput { click() }
219                             // Ideally, there would be a way to await the end of all pending
220                             // animations. That is currently not possible (while also manually
221                             // advancing the time, see http://shortn/_3WkhxugOzv), so this waits
222                             // for a fixed duration. Since the tests are stable, the animation
223                             // duration will always be the same - and if it changes, one has to
224                             // update the golden anyways, so this is not a huge issue.
225                             // Alternatively, the production code has to be instrumented, and track
226                             // the animation end manually.
227                             awaitDelay(300.milliseconds)
228                             onNodeWithTag("foo").performTouchInput { click() }
229                             awaitDelay(300.milliseconds)
230                         }
231                     ) {
232                         feature(hasTestTag("foo"), ComposeFeatureCaptures.dpSize)
233                     },
234                 )
235             motionRule.assertThat(motion).timeSeriesMatchesGolden()
236         }
237 
238     @Composable
239     private fun DraggableContent() {
240         var pointerPosition by remember { mutableStateOf(Offset.Unspecified) }
241 
242         Box(
243             modifier =
244                 Modifier.pointerInput(Unit) {
245                         detectDragGestures(
246                             onDragStart = { pointerPosition = it },
247                             onDragEnd = { pointerPosition = Offset.Unspecified },
248                         ) { _, dragAmount ->
249                             pointerPosition =
250                                 if (pointerPosition.isSpecified) pointerPosition + dragAmount
251                                 else dragAmount
252                         }
253                     }
254                     .testTag("foo")
255                     .motionTestValues { pointerPosition exportAs pointerOffsetKey }
256                     .size(300.dp)
257                     .drawBehind {
258                         if (pointerPosition.isSpecified) {
259                             drawCircle(Color.Red, radius = 5.dp.toPx(), center = pointerPosition)
260                         }
261                     }
262         )
263     }
264 
265     @Test
266     fun recordMotion_motionControl_performGesture_notRecordableWithDefaultImplementations() =
267         motionRule.runTest {
268             val motion =
269                 recordMotion(
270                     content = { DraggableContent() },
271                     ComposeRecordingSpec(
272                         recording = {
273                             // For the purpose of this test, make sure one frame is recorded before
274                             // the (undesired) blocking swipeDown call is performed. That ensures
275                             // that the golden will actually indicate the skipped frames.
276                             awaitFrames(1)
277 
278                             onNodeWithTag("foo").performTouchInput {
279                                 // The regular gesture functions do advance time internally;
280                                 // the motion test will not record frames while performing the
281                                 // gesture.
282                                 // It might still be useful to get the test subject into some other
283                                 // state.
284                                 swipeDown(durationMillis = 100, startY = top)
285                             }
286                             awaitDelay(200.milliseconds)
287                         }
288                     ) {
289                         feature(pointerOffsetKey, offset)
290                     },
291                 )
292 
293             // The golden is expected to be missing the first 100ms, while the `swipeDown` executes.
294             motionRule.assertThat(motion).timeSeriesMatchesGolden()
295         }
296 
297     @Test
298     fun recordMotion_motionControl_performGestureAsync() =
299         motionRule.runTest {
300             val motion =
301                 recordMotion(
302                     content = { DraggableContent() },
303                     ComposeRecordingSpec(
304                         recording = {
305                             performTouchInputAsync(onNodeWithTag("foo")) {
306                                 swipeDown(durationMillis = 100)
307                             }
308                             // Await an extra frame to capture the up event in the golden
309                             awaitFrames(1)
310                         }
311                     ) {
312                         feature(pointerOffsetKey, offset)
313                     },
314                 )
315 
316             motionRule.assertThat(motion).timeSeriesMatchesGolden()
317         }
318 
319     @Test
320     fun recordMotion_motionControl_performGesture_sendIndividualEvents() =
321         motionRule.runTest {
322             val motion =
323                 recordMotion(
324                     content = { DraggableContent() },
325                     ComposeRecordingSpec(
326                         recording = {
327                             val dragSurface = onNodeWithTag("foo")
328 
329                             // Motion tests that record the results of a gesture must send
330                             // individual events.
331                             // TODO(b/322324387): Either work with compose to allow using regular
332                             // test gesture control, or at least supply the similar convenience
333                             // helper functions.
334                             dragSurface.performTouchInput { down(topCenter) }
335                             repeat(20) { i ->
336                                 dragSurface.performTouchInput {
337                                     moveTo(lerp(topCenter, bottomCenter, i / 20f), delayMillis = 0)
338                                 }
339                                 awaitFrames(1)
340                             }
341                             dragSurface.performTouchInput { up() }
342                         }
343                     ) {
344                         feature(pointerOffsetKey, offset)
345                     },
346                 )
347 
348             motionRule.assertThat(motion).timeSeriesMatchesGolden()
349         }
350 
351     /**
352      * Helper to assert the exact timing of the recording.
353      *
354      * Returns an [IterableSubject] identifying the frames *recorded*.
355      *
356      * This helper runs `recordMotion(testContent, motionControl)`. The testContent is Composable
357      * that increases a frame counter on each animation frame. A time series of that frame count
358      * value is what is captured and returned for assertion on the [IterableSubject].
359      *
360      * The frame count will start at 0 for the first composition, and increase up to 16, at which
361      * point the `testContent` composable will be idle.
362      *
363      * The observed frame counts is offset by `100` as soon as the `play` flag is true, to make it
364      * easy to assert the exact timing of the flipping. When [recordBefore] or [recordAfter] are set
365      * to true, the first/last entry in the returned sequence denote the recorded before/after
366      * frames.
367      *
368      * The actual captured frames reflect the internals of the motion test, as the
369      * [MotionControlImpl] cycles through the [MotionControlState]s. The following chart illustrates
370      * this.
371      *
372      * ```
373      * Clock         :  0ms  16ms 32ms 48ms  ... 256ms
374      * Frame count   :  0    1    2    103   ... 116
375      * Animation Time:  -    0ms  16ms 32ms  ... 240ms
376      * Events        :  │    │    │  │ │         └ testContent is idle (animation finished)
377      *                  │    │    │  │ └  motionControl in `Recording` state
378      *                  │    │    │  └ before state captured, flipping `play` to true
379      *                  │    │    └  motionControl in `ReadyToPlay`
380      *                  │    └ Animation first frame (value 0), motionControl in `Start` state
381      *                  └ testContent enters composition, rule calls `waitForIdle`
382      * ```
383      */
384     private fun MotionTestRule<ComposeToolkit>.assertThatFrameCountValues(
385         recordBefore: Boolean,
386         recordAfter: Boolean,
387         motionControl: MotionControl,
388     ) = assertThatFrameCountValuesImpl(recordBefore, recordAfter, motionControl)
389 
390     @Test
391     fun recordMotion_motionControl_recordDurationOnly() =
392         motionRule.runTest {
393             assertThatFrameCountValues(
394                     recordBefore = true,
395                     recordAfter = false,
396                     MotionControl { awaitFrames(5) },
397                 )
398                 // Minimum delays, play flag flipped after 2
399                 .containsExactly(/* before */ 2, 103, 104, 105, 106, 107)
400                 .inOrder()
401         }
402 
403     @Test
404     fun recordMotion_motionControl_recordDurationOnly_withoutBefore() =
405         motionRule.runTest {
406             assertThatFrameCountValues(
407                     recordBefore = false,
408                     recordAfter = false,
409                     MotionControl { awaitFrames(5) },
410                 )
411                 // Same as above, just not recording before. Must not make a difference
412                 .containsExactly(103, 104, 105, 106, 107)
413                 .inOrder()
414         }
415 
416     @Test
417     fun recordMotion_motionControl_recordBeforeAndAfter() =
418         motionRule.runTest {
419             assertThatFrameCountValues(
420                     recordBefore = true,
421                     recordAfter = true,
422                     MotionControl { awaitFrames(1) },
423                 )
424                 // after represents the state when the composable is idle, no matter how long the
425                 // recording took
426                 .containsExactly(/* before */ 2, 103, /* after */ 116)
427                 .inOrder()
428         }
429 
430     @Test
431     fun recordMotion_motionControl_delayStartRecording() =
432         motionRule.runTest {
433             assertThatFrameCountValues(
434                     recordBefore = true,
435                     recordAfter = false,
436                     MotionControl(delayRecording = { awaitFrames(2) }) { awaitFrames(5) },
437                 )
438                 // Start recording is delayed, readyToPlay is still after frame 2 (before is
439                 // captured
440                 // just before the flag is flipped)
441                 .containsExactly(/* before */ 2, 105, 106, 107, 108, 109)
442                 .inOrder()
443         }
444 
445     @Test
446     fun recordMotion_motionControl_delayReadyToPlay() =
447         motionRule.runTest {
448             assertThatFrameCountValues(
449                     recordBefore = true,
450                     recordAfter = false,
451                     MotionControl(delayReadyToPlay = { awaitFrames(2) }) { awaitFrames(5) },
452                 )
453                 // delaying readyToPlay pushes back the before recording
454                 .containsExactly(/* before */ 4, 105, 106, 107, 108, 109)
455                 .inOrder()
456         }
457 
458     @Test
459     fun recordMotion_motionControl_delayPlayAndRecording() =
460         motionRule.runTest {
461             assertThatFrameCountValues(
462                     recordBefore = true,
463                     recordAfter = false,
464                     MotionControl(
465                         delayReadyToPlay = { awaitFrames(2) },
466                         delayRecording = { awaitFrames(3) },
467                     ) {
468                         awaitFrames(5)
469                     },
470                 )
471                 .containsExactly(/* before */ 4, 108, 109, 110, 111, 112)
472                 .inOrder()
473         }
474 
475     @Test
476     fun recordMotion_motionControl_awaitDelay_10ms_skipsOneFrame() =
477         motionRule.runTest {
478             assertThatFrameCountValues(
479                     recordBefore = false,
480                     recordAfter = false,
481                     MotionControl { awaitDelay(10.milliseconds) },
482                 )
483                 .hasSize(1)
484         }
485 
486     @Test
487     fun recordMotion_motionControl_awaitDelay_16ms_skipsOneFrame() =
488         motionRule.runTest {
489             assertThatFrameCountValues(
490                     recordBefore = false,
491                     recordAfter = false,
492                     MotionControl { awaitDelay(16.milliseconds) },
493                 )
494                 .hasSize(1)
495         }
496 
497     @Test
498     fun recordMotion_motionControl_awaitDelay_17ms_skipsTwoFrames() =
499         motionRule.runTest {
500             assertThatFrameCountValues(
501                     recordBefore = false,
502                     recordAfter = false,
503                     MotionControl { awaitDelay(17.milliseconds) },
504                 )
505                 .hasSize(2)
506         }
507 
508     @Test
509     fun recordMotion_motionControl_awaitDelay_delayStartsAfterImmediately() =
510         motionRule.runTest {
511             assertThatFrameCountValues(
512                     recordBefore = false,
513                     recordAfter = false,
514                     MotionControl {
515                         // 20ms
516                         awaitDelay(10.milliseconds)
517                         awaitDelay(10.milliseconds)
518                     },
519                 )
520                 .hasSize(2)
521         }
522 
523     @Test
524     fun recordMotion_motionControl_awaitDelay_roundsUpToFullDelay() =
525         motionRule.runTest {
526             assertThatFrameCountValues(
527                     recordBefore = false,
528                     recordAfter = false,
529                     MotionControl { awaitDelay(10.milliseconds) },
530                 )
531                 .hasSize(1)
532         }
533 
534     @Test
535     fun recordMotion_motionControl_awaitCondition() =
536         motionRule.runTest {
537             val checkConditionInvocationFrames = mutableListOf<Int>()
538 
539             assertThatFrameCountValues(
540                     recordBefore = true,
541                     recordAfter = false,
542                     MotionControl {
543                         awaitCondition {
544                             val currentFrameCount = motionTestValueOfNode(frameCountKey)
545                             checkConditionInvocationFrames.add(currentFrameCount)
546                             currentFrameCount == 105
547                         }
548                     },
549                 )
550                 // Must not record the frame where the condition returned true
551                 .containsExactly(/* before */ 2, 103, 104)
552                 .inOrder()
553 
554             assertThat(checkConditionInvocationFrames).containsExactly(103, 104, 105).inOrder()
555         }
556 
557     @Test
558     fun recordMotion_motionControl_awaitConditionOnSignals() =
559         motionRule.runTest {
560             val awaitReadyToPlayInvocationFrames = mutableListOf<Int>()
561             val awaitStartRecordingInvocationFrames = mutableListOf<Int>()
562             val awaitAnimationEndInvocationFrames = mutableListOf<Int>()
563 
564             assertThatFrameCountValues(
565                     recordBefore = true,
566                     recordAfter = false,
567                     MotionControl(
568                         delayReadyToPlay = {
569                             awaitCondition {
570                                 motionTestValueOfNode(frameCountKey)
571                                     .also(awaitReadyToPlayInvocationFrames::add) == 5
572                             }
573                         },
574                         delayRecording = {
575                             awaitCondition {
576                                 motionTestValueOfNode(frameCountKey)
577                                     .also(awaitStartRecordingInvocationFrames::add) == 107
578                             }
579                         },
580                     ) {
581                         awaitCondition {
582                             motionTestValueOfNode(frameCountKey)
583                                 .also(awaitAnimationEndInvocationFrames::add) == 110
584                         }
585                     },
586                 )
587                 .containsExactly(/* before */ 6, 108, 109)
588                 .inOrder()
589 
590             assertThat(awaitReadyToPlayInvocationFrames).containsExactly(1, 2, 3, 4, 5).inOrder()
591             // 6 (and not 106) as this method is invoked immediately after readyToPlay was flipped,
592             // and no recomposition happened yet.
593             assertThat(awaitStartRecordingInvocationFrames).containsExactly(6, 107).inOrder()
594             assertThat(awaitAnimationEndInvocationFrames).containsExactly(108, 109, 110).inOrder()
595         }
596 
597     /** @see assertThatFrameCountValues */
598     private fun MotionTestRule<ComposeToolkit>.assertThatFrameCountValuesImpl(
599         recordBefore: Boolean,
600         recordAfter: Boolean,
601         motionControl: MotionControl,
602     ): IterableSubject {
603         val motion =
604             recordMotion(
605                 content = { play ->
606                     var frameCount by remember { mutableStateOf(0) }
607                     LaunchedEffect(Unit) {
608                         val animatable = Animatable(0f)
609                         launch {
610                             // frameCount must end at 16 when the animation ends (some tests
611                             // capture this)
612                             val testMaxFrameCount = 16
613 
614                             // Compute the animation duration that ensures the callback will be
615                             // invoked exactly testMaxFrameCount:  Compose runs test animations at
616                             // exactly 16ms/frame, and the first animation frame is for animation
617                             // value 0.
618                             val animationDurationMillis = (testMaxFrameCount - 1) * 16
619                             animatable.animateTo(1f, tween(animationDurationMillis)) {
620                                 frameCount++
621                             }
622                         }
623                     }
624                     val exportedFrameCount = if (play) frameCount + 100 else frameCount
625                     Text(
626                         text = "$exportedFrameCount",
627                         modifier =
628                             Modifier.motionTestValues { exportedFrameCount exportAs frameCountKey },
629                     )
630                 },
631                 ComposeRecordingSpec(motionControl, recordBefore, recordAfter) {
632                     feature(frameCountKey, DataPointTypes.int)
633                 },
634             )
635 
636         val frameCountValues =
637             checkNotNull(motion.timeSeries.features["frameCount"]).dataPoints.map { dataPoint ->
638                 when (dataPoint) {
639                     is ValueDataPoint -> dataPoint.value
640                     is NotFoundDataPoint -> null
641                     else -> throw AssertionError()
642                 }
643             }
644 
645         return assertThat(frameCountValues)
646     }
647 
648     companion object {
649         private val frameCountKey = MotionTestValueKey<Int>("frameCount")
650         private val pointerOffsetKey = MotionTestValueKey<Offset>("pointerOffset")
651     }
652 }
653