xref: /aosp_15_r20/platform_testing/libraries/motion/compose/src/platform/test/motion/compose/ComposeToolkit.kt (revision dd0948b35e70be4c0246aabd6c72554a5eb8b22a)
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 android.util.Log
20 import androidx.compose.runtime.Composable
21 import androidx.compose.runtime.getValue
22 import androidx.compose.runtime.mutableStateOf
23 import androidx.compose.runtime.setValue
24 import androidx.compose.ui.geometry.Offset
25 import androidx.compose.ui.graphics.ImageBitmap
26 import androidx.compose.ui.graphics.asAndroidBitmap
27 import androidx.compose.ui.graphics.asImageBitmap
28 import androidx.compose.ui.platform.ViewConfiguration
29 import androidx.compose.ui.platform.ViewRootForTest
30 import androidx.compose.ui.test.ExperimentalTestApi
31 import androidx.compose.ui.test.SemanticsNodeInteraction
32 import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
33 import androidx.compose.ui.test.TouchInjectionScope
34 import androidx.compose.ui.test.junit4.ComposeContentTestRule
35 import androidx.compose.ui.test.junit4.ComposeTestRule
36 import androidx.compose.ui.test.junit4.createComposeRule
37 import androidx.compose.ui.test.onRoot
38 import androidx.compose.ui.test.performTouchInput
39 import androidx.compose.ui.unit.Density
40 import androidx.compose.ui.unit.IntSize
41 import java.util.concurrent.TimeUnit
42 import kotlin.math.roundToInt
43 import kotlin.time.Duration
44 import kotlin.time.Duration.Companion.milliseconds
45 import kotlin.time.Duration.Companion.seconds
46 import kotlinx.coroutines.ExperimentalCoroutinesApi
47 import kotlinx.coroutines.Job
48 import kotlinx.coroutines.flow.MutableStateFlow
49 import kotlinx.coroutines.flow.asStateFlow
50 import kotlinx.coroutines.flow.take
51 import kotlinx.coroutines.flow.takeWhile
52 import kotlinx.coroutines.launch
53 import kotlinx.coroutines.test.TestScope
54 import kotlinx.coroutines.test.runCurrent
55 import kotlinx.coroutines.test.runTest
56 import org.junit.rules.RuleChain
57 import platform.test.motion.MotionTestRule
58 import platform.test.motion.RecordedMotion
59 import platform.test.motion.RecordedMotion.Companion.create
60 import platform.test.motion.compose.ComposeToolkit.Companion.TAG
61 import platform.test.motion.compose.values.EnableMotionTestValueCollection
62 import platform.test.motion.golden.DataPoint
63 import platform.test.motion.golden.Feature
64 import platform.test.motion.golden.FrameId
65 import platform.test.motion.golden.SupplementalFrameId
66 import platform.test.motion.golden.TimeSeries
67 import platform.test.motion.golden.TimeSeriesCaptureScope
68 import platform.test.motion.golden.TimestampFrameId
69 import platform.test.screenshot.DeviceEmulationRule
70 import platform.test.screenshot.DeviceEmulationSpec
71 import platform.test.screenshot.Displays
72 import platform.test.screenshot.GoldenPathManager
73 import platform.test.screenshot.captureToBitmapAsync
74 
75 /** Toolkit to support Compose-based [MotionTestRule] tests. */
76 class ComposeToolkit(val composeContentTestRule: ComposeContentTestRule, val testScope: TestScope) {
77     internal companion object {
78         const val TAG = "ComposeToolkit"
79     }
80 }
81 
82 /** Runs a motion test in the [ComposeToolkit.testScope] */
MotionTestRulenull83 fun MotionTestRule<ComposeToolkit>.runTest(
84     timeout: Duration = 20.seconds,
85     testBody: suspend MotionTestRule<ComposeToolkit>.() -> Unit,
86 ) {
87     val motionTestRule = this
88     toolkit.testScope.runTest(timeout) { testBody.invoke(motionTestRule) }
89 }
90 
91 /**
92  * Convenience to create a [MotionTestRule], including the required setup.
93  *
94  * In addition to the [MotionTestRule], this function also creates a [DeviceEmulationRule] and
95  * [ComposeContentTestRule], and ensures these are run as part of the [MotionTestRule].
96  */
97 @OptIn(ExperimentalTestApi::class)
createComposeMotionTestRulenull98 fun createComposeMotionTestRule(
99     goldenPathManager: GoldenPathManager,
100     testScope: TestScope = TestScope(),
101     deviceEmulationSpec: DeviceEmulationSpec = DeviceEmulationSpec(Displays.Phone),
102 ): MotionTestRule<ComposeToolkit> {
103     val deviceEmulationRule = DeviceEmulationRule(deviceEmulationSpec)
104     val composeRule = createComposeRule(testScope.coroutineContext)
105 
106     return MotionTestRule(
107         ComposeToolkit(composeRule, testScope),
108         goldenPathManager,
109         extraRules = RuleChain.outerRule(deviceEmulationRule).around(composeRule),
110     )
111 }
112 
113 /**
114  * Controls the timing of the motion recording.
115  *
116  * The time series is recorded while the [recording] function is running.
117  *
118  * @param delayReadyToPlay allows delaying flipping the `play` parameter of the [recordMotion]'s
119  *   content composable to true.
120  * @param delayRecording allows delaying the first recorded frame, after the animation started.
121  */
122 class MotionControl(
<lambda>null123     val delayReadyToPlay: MotionControlFn = {},
<lambda>null124     val delayRecording: MotionControlFn = {},
125     val recording: MotionControlFn,
126 )
127 
128 typealias MotionControlFn = suspend MotionControlScope.() -> Unit
129 
130 interface MotionControlScope : SemanticsNodeInteractionsProvider {
131     /** Waits until [check] returns true. Invoked on each frame. */
awaitConditionnull132     suspend fun awaitCondition(check: () -> Boolean)
133 
134     /** Waits for [count] frames to be processed. */
135     suspend fun awaitFrames(count: Int = 1)
136 
137     /** Waits for [duration] to pass. */
138     suspend fun awaitDelay(duration: Duration)
139 
140     /**
141      * Performs touch input, and waits for the completion thereof.
142      *
143      * NOTE: Do use this function instead of [SemanticsNodeInteraction.performTouchInput], since
144      * `performTouchInput` will also advance the time of the compose clock, making it impossible to
145      * record motion while performing gestures.
146      */
147     suspend fun performTouchInputAsync(
148         onNode: SemanticsNodeInteraction,
149         gestureControl: TouchInjectionScope.() -> Unit,
150     )
151 }
152 
153 /**
154  * Defines the sampling of features during a test run.
155  *
156  * @param motionControl defines the timing for the recording.
157  * @param recordBefore Records the frame just before the animation is started (immediately before
158  *   flipping the `play` parameter of the [recordMotion]'s content composable)
159  * @param recordAfter Records the frame after the recording has ended (runs after awaiting idleness,
160  *   after all animations have finished and no more recomposition is pending).
161  * @param timeSeriesCapture produces the time-series, invoked on each animation frame.
162  */
163 data class ComposeRecordingSpec(
164     val motionControl: MotionControl,
165     val recordBefore: Boolean = true,
166     val recordAfter: Boolean = true,
167     val timeSeriesCapture: TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.() -> Unit,
168 ) {
169     constructor(
170         recording: MotionControlFn,
171         recordBefore: Boolean = true,
172         recordAfter: Boolean = true,
173         timeSeriesCapture: TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.() -> Unit,
174     ) : this(MotionControl(recording = recording), recordBefore, recordAfter, timeSeriesCapture)
175 
176     companion object {
177         /** Record a time-series until [checkDone] returns true. */
178         fun until(
179             checkDone: SemanticsNodeInteractionsProvider.() -> Boolean,
180             recordBefore: Boolean = true,
181             recordAfter: Boolean = true,
182             timeSeriesCapture: TimeSeriesCaptureScope<SemanticsNodeInteractionsProvider>.() -> Unit,
183         ): ComposeRecordingSpec {
184             return ComposeRecordingSpec(
185                 motionControl = MotionControl { awaitCondition { checkDone() } },
186                 recordBefore,
187                 recordAfter,
188                 timeSeriesCapture,
189             )
190         }
191     }
192 }
193 
194 /**
195  * Composes [content] and records the time-series of the features specified in [recordingSpec].
196  *
197  * The animation is recorded between flipping [content]'s `play` parameter to `true`, until the
198  * [ComposeRecordingSpec.motionControl] finishes.
199  */
recordMotionnull200 fun MotionTestRule<ComposeToolkit>.recordMotion(
201     content: @Composable (play: Boolean) -> Unit,
202     recordingSpec: ComposeRecordingSpec,
203 ): RecordedMotion {
204     with(toolkit.composeContentTestRule) {
205         val frameIdCollector = mutableListOf<FrameId>()
206         val propertyCollector = mutableMapOf<String, MutableList<DataPoint<*>>>()
207         val screenshotCollector = mutableListOf<ImageBitmap>()
208 
209         fun recordFrame(frameId: FrameId) {
210             Log.i(TAG, "recordFrame($frameId)")
211             frameIdCollector.add(frameId)
212             recordingSpec.timeSeriesCapture.invoke(TimeSeriesCaptureScope(this, propertyCollector))
213 
214             val view = (onRoot().fetchSemanticsNode().root as ViewRootForTest).view
215             val bitmap = view.captureToBitmapAsync().get(10, TimeUnit.SECONDS)
216             screenshotCollector.add(bitmap.asImageBitmap())
217         }
218 
219         var playbackStarted by mutableStateOf(false)
220 
221         mainClock.autoAdvance = false
222 
223         setContent { EnableMotionTestValueCollection { content(playbackStarted) } }
224         Log.i(TAG, "recordMotion() created compose content")
225 
226         waitForIdle()
227 
228         val motionControl =
229             MotionControlImpl(
230                 toolkit.composeContentTestRule,
231                 toolkit.testScope,
232                 recordingSpec.motionControl,
233             )
234 
235         Log.i(TAG, "recordMotion() awaiting readyToPlay")
236 
237         // Wait for the test to allow readyToPlay
238         while (!motionControl.readyToPlay) {
239             motionControl.nextFrame()
240         }
241 
242         if (recordingSpec.recordBefore) {
243             recordFrame(SupplementalFrameId("before"))
244         }
245         Log.i(TAG, "recordMotion() awaiting recordingStarted")
246 
247         playbackStarted = true
248         while (!motionControl.recordingStarted) {
249             motionControl.nextFrame()
250         }
251 
252         Log.i(TAG, "recordMotion() begin recording")
253 
254         val startFrameTime = mainClock.currentTime
255         while (!motionControl.recordingEnded) {
256             recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
257             motionControl.nextFrame()
258         }
259 
260         Log.i(TAG, "recordMotion() end recording")
261 
262         mainClock.autoAdvance = true
263         waitForIdle()
264 
265         if (recordingSpec.recordAfter) {
266             recordFrame(SupplementalFrameId("after"))
267         }
268 
269         val timeSeries =
270             TimeSeries(
271                 frameIdCollector.toList(),
272                 propertyCollector.entries.map { entry -> Feature(entry.key, entry.value) },
273             )
274 
275         return create(timeSeries, screenshotCollector.map { it.asAndroidBitmap() })
276     }
277 }
278 
279 enum class MotionControlState {
280     Start,
281     WaitingToPlay,
282     WaitingToRecord,
283     Recording,
284     Ended,
285 }
286 
287 @OptIn(ExperimentalCoroutinesApi::class)
288 private class MotionControlImpl(
289     val composeTestRule: ComposeTestRule,
290     val testScope: TestScope,
291     val motionControl: MotionControl,
<lambda>null292 ) : MotionControlScope, SemanticsNodeInteractionsProvider by composeTestRule {
293 
294     private var state = MotionControlState.Start
295     private lateinit var delayReadyToPlayJob: Job
296     private lateinit var delayRecordingJob: Job
297     private lateinit var recordingJob: Job
298 
299     private val frameEmitter = MutableStateFlow<Long>(0)
300     private val onFrame = frameEmitter.asStateFlow()
301 
302     val readyToPlay: Boolean
303         get() =
304             when (state) {
305                 MotionControlState.Start,
306                 MotionControlState.WaitingToPlay -> false
307                 else -> true
308             }
309 
310     val recordingStarted: Boolean
311         get() =
312             when (state) {
313                 MotionControlState.Recording,
314                 MotionControlState.Ended -> true
315                 else -> false
316             }
317 
318     val recordingEnded: Boolean
319         get() =
320             when (state) {
321                 MotionControlState.Ended -> true
322                 else -> false
323             }
324 
325     fun nextFrame() {
326         composeTestRule.mainClock.advanceTimeByFrame()
327         composeTestRule.waitForIdle()
328 
329         when (state) {
330             MotionControlState.Start -> {
331                 delayReadyToPlayJob = motionControl.delayReadyToPlay.launch()
332                 state = MotionControlState.WaitingToPlay
333             }
334             MotionControlState.WaitingToPlay -> {
335                 if (delayReadyToPlayJob.isCompleted) {
336                     delayRecordingJob = motionControl.delayRecording.launch()
337                     state = MotionControlState.WaitingToRecord
338                 }
339             }
340             MotionControlState.WaitingToRecord -> {
341                 if (delayRecordingJob.isCompleted) {
342                     recordingJob = motionControl.recording.launch()
343                     state = MotionControlState.Recording
344                 }
345             }
346             MotionControlState.Recording -> {
347                 if (recordingJob.isCompleted) {
348                     state = MotionControlState.Ended
349                 }
350             }
351             MotionControlState.Ended -> {}
352         }
353 
354         frameEmitter.tryEmit(composeTestRule.mainClock.currentTime)
355         testScope.runCurrent()
356 
357         composeTestRule.waitForIdle()
358 
359         if (state == MotionControlState.Recording && recordingJob.isCompleted) {
360             state = MotionControlState.Ended
361         }
362     }
363 
364     override suspend fun awaitFrames(count: Int) {
365         // Since this is a state-flow, the current frame is counted too. This condition must wait
366         // for an additional frame to fulfill the contract
367         onFrame.take(count + 1).collect {}
368     }
369 
370     override suspend fun awaitDelay(duration: Duration) {
371         val endTime = onFrame.value + duration.inWholeMilliseconds
372         onFrame.takeWhile { it < endTime }.collect {}
373     }
374 
375     override suspend fun awaitCondition(check: () -> Boolean) {
376         onFrame.takeWhile { !check() }.collect {}
377     }
378 
379     override suspend fun performTouchInputAsync(
380         onNode: SemanticsNodeInteraction,
381         gestureControl: TouchInjectionScope.() -> Unit,
382     ) {
383         val node = onNode.fetchSemanticsNode()
384         val density = node.layoutInfo.density
385         val viewConfiguration = node.layoutInfo.viewConfiguration
386         val visibleSize =
387             with(node.boundsInRoot) { IntSize(width.roundToInt(), height.roundToInt()) }
388 
389         val touchEventRecorder = TouchEventRecorder(density, viewConfiguration, visibleSize)
390         gestureControl(touchEventRecorder)
391 
392         val recordedEntries = touchEventRecorder.recordedEntries
393         for (entry in recordedEntries) {
394             when (entry) {
395                 is TouchEventRecorderEntry.AdvanceTime ->
396                     awaitDelay(entry.durationMillis.milliseconds)
397                 is TouchEventRecorderEntry.Cancel ->
398                     onNode.performTouchInput { cancel(delayMillis = 0) }
399                 is TouchEventRecorderEntry.Down ->
400                     onNode.performTouchInput { down(entry.pointerId, entry.position) }
401                 is TouchEventRecorderEntry.Move ->
402                     onNode.performTouchInput { move(delayMillis = 0) }
403                 is TouchEventRecorderEntry.Up -> onNode.performTouchInput { up(entry.pointerId) }
404                 is TouchEventRecorderEntry.UpdatePointerTo ->
405                     onNode.performTouchInput { updatePointerTo(entry.pointerId, entry.position) }
406             }
407         }
408     }
409 
410     private fun MotionControlFn.launch(): Job {
411         val function = this
412         return testScope.launch { function() }
413     }
414 }
415 
416 /** Records the invocations of the [TouchInjectionScope] methods. */
417 private sealed interface TouchEventRecorderEntry {
418 
419     class AdvanceTime(val durationMillis: Long) : TouchEventRecorderEntry
420 
421     object Cancel : TouchEventRecorderEntry
422 
423     class Down(val pointerId: Int, val position: Offset) : TouchEventRecorderEntry
424 
425     object Move : TouchEventRecorderEntry
426 
427     class Up(val pointerId: Int) : TouchEventRecorderEntry
428 
429     class UpdatePointerTo(val pointerId: Int, val position: Offset) : TouchEventRecorderEntry
430 }
431 
432 private class TouchEventRecorder(
433     density: Density,
434     override val viewConfiguration: ViewConfiguration,
435     override val visibleSize: IntSize,
<lambda>null436 ) : TouchInjectionScope, Density by density {
437 
438     val lastPositions = mutableMapOf<Int, Offset>()
439     val recordedEntries = mutableListOf<TouchEventRecorderEntry>()
440 
441     override fun advanceEventTime(durationMillis: Long) {
442         if (durationMillis > 0) {
443             recordedEntries.add(TouchEventRecorderEntry.AdvanceTime(durationMillis))
444         }
445     }
446 
447     override fun cancel(delayMillis: Long) {
448         advanceEventTime(delayMillis)
449         recordedEntries.add(TouchEventRecorderEntry.Cancel)
450     }
451 
452     override fun currentPosition(pointerId: Int): Offset? {
453         return lastPositions[pointerId]
454     }
455 
456     override fun down(pointerId: Int, position: Offset) {
457         recordedEntries.add(TouchEventRecorderEntry.Down(pointerId, position))
458         lastPositions[pointerId] = position
459     }
460 
461     override fun move(delayMillis: Long) {
462         advanceEventTime(delayMillis)
463         recordedEntries.add(TouchEventRecorderEntry.Move)
464     }
465 
466     @ExperimentalTestApi
467     override fun moveWithHistoryMultiPointer(
468         relativeHistoricalTimes: List<Long>,
469         historicalCoordinates: List<List<Offset>>,
470         delayMillis: Long,
471     ) {
472         TODO("Not yet supported")
473     }
474 
475     override fun up(pointerId: Int) {
476         recordedEntries.add(TouchEventRecorderEntry.Up(pointerId))
477         lastPositions.remove(pointerId)
478     }
479 
480     override fun updatePointerTo(pointerId: Int, position: Offset) {
481         recordedEntries.add(TouchEventRecorderEntry.UpdatePointerTo(pointerId, position))
482         lastPositions[pointerId] = position
483     }
484 }
485