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.platform.ViewConfiguration
28 import androidx.compose.ui.test.ExperimentalTestApi
29 import androidx.compose.ui.test.SemanticsNodeInteraction
30 import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
31 import androidx.compose.ui.test.TouchInjectionScope
32 import androidx.compose.ui.test.captureToImage
33 import androidx.compose.ui.test.junit4.ComposeContentTestRule
34 import androidx.compose.ui.test.junit4.ComposeTestRule
35 import androidx.compose.ui.test.junit4.createComposeRule
36 import androidx.compose.ui.test.onRoot
37 import androidx.compose.ui.test.performTouchInput
38 import androidx.compose.ui.unit.Density
39 import androidx.compose.ui.unit.IntSize
40 import kotlin.math.roundToInt
41 import kotlin.time.Duration
42 import kotlin.time.Duration.Companion.milliseconds
43 import kotlin.time.Duration.Companion.seconds
44 import kotlinx.coroutines.ExperimentalCoroutinesApi
45 import kotlinx.coroutines.Job
46 import kotlinx.coroutines.flow.MutableStateFlow
47 import kotlinx.coroutines.flow.asStateFlow
48 import kotlinx.coroutines.flow.take
49 import kotlinx.coroutines.flow.takeWhile
50 import kotlinx.coroutines.launch
51 import kotlinx.coroutines.test.TestScope
52 import kotlinx.coroutines.test.runCurrent
53 import kotlinx.coroutines.test.runTest
54 import org.junit.rules.RuleChain
55 import platform.test.motion.MotionTestRule
56 import platform.test.motion.RecordedMotion
57 import platform.test.motion.RecordedMotion.Companion.create
58 import platform.test.motion.compose.ComposeToolkit.Companion.TAG
59 import platform.test.motion.compose.values.EnableMotionTestValueCollection
60 import platform.test.motion.golden.DataPoint
61 import platform.test.motion.golden.Feature
62 import platform.test.motion.golden.FrameId
63 import platform.test.motion.golden.SupplementalFrameId
64 import platform.test.motion.golden.TimeSeries
65 import platform.test.motion.golden.TimeSeriesCaptureScope
66 import platform.test.motion.golden.TimestampFrameId
67 import platform.test.screenshot.DeviceEmulationRule
68 import platform.test.screenshot.DeviceEmulationSpec
69 import platform.test.screenshot.Displays
70 import platform.test.screenshot.GoldenPathManager
71
72 /** Toolkit class to use for View-based [MotionTestRule] tests. */
73 class ComposeToolkit(
74 val composeContentTestRule: ComposeContentTestRule,
75 val testScope: TestScope,
76 ) {
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 screenshotCollector.add(onRoot().captureToImage())
214 }
215
216 var playbackStarted by mutableStateOf(false)
217
218 mainClock.autoAdvance = false
219
220 setContent { EnableMotionTestValueCollection { content(playbackStarted) } }
221 Log.i(TAG, "recordMotion() created compose content")
222
223 waitForIdle()
224
225 val motionControl =
226 MotionControlImpl(
227 toolkit.composeContentTestRule,
228 toolkit.testScope,
229 recordingSpec.motionControl
230 )
231
232 Log.i(TAG, "recordMotion() awaiting readyToPlay")
233
234 // Wait for the test to allow readyToPlay
235 while (!motionControl.readyToPlay) {
236 motionControl.nextFrame()
237 }
238
239 if (recordingSpec.recordBefore) {
240 recordFrame(SupplementalFrameId("before"))
241 }
242 Log.i(TAG, "recordMotion() awaiting recordingStarted")
243
244 playbackStarted = true
245 while (!motionControl.recordingStarted) {
246 motionControl.nextFrame()
247 }
248
249 Log.i(TAG, "recordMotion() begin recording")
250
251 val startFrameTime = mainClock.currentTime
252 while (!motionControl.recordingEnded) {
253 recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
254 motionControl.nextFrame()
255 }
256
257 Log.i(TAG, "recordMotion() end recording")
258
259 mainClock.autoAdvance = true
260 waitForIdle()
261
262 if (recordingSpec.recordAfter) {
263 recordFrame(SupplementalFrameId("after"))
264 }
265
266 val timeSeries =
267 TimeSeries(
268 frameIdCollector.toList(),
269 propertyCollector.entries.map { entry -> Feature(entry.key, entry.value) }
270 )
271
272 return create(timeSeries, screenshotCollector.map { it.asAndroidBitmap() })
273 }
274 }
275
276 enum class MotionControlState {
277 Start,
278 WaitingToPlay,
279 WaitingToRecord,
280 Recording,
281 Ended,
282 }
283
284 @OptIn(ExperimentalCoroutinesApi::class)
285 private class MotionControlImpl(
286 val composeTestRule: ComposeTestRule,
287 val testScope: TestScope,
288 val motionControl: MotionControl
<lambda>null289 ) : MotionControlScope, SemanticsNodeInteractionsProvider by composeTestRule {
290
291 private var state = MotionControlState.Start
292 private lateinit var delayReadyToPlayJob: Job
293 private lateinit var delayRecordingJob: Job
294 private lateinit var recordingJob: Job
295
296 private val frameEmitter = MutableStateFlow<Long>(0)
297 private val onFrame = frameEmitter.asStateFlow()
298
299 val readyToPlay: Boolean
300 get() =
301 when (state) {
302 MotionControlState.Start,
303 MotionControlState.WaitingToPlay -> false
304 else -> true
305 }
306
307 val recordingStarted: Boolean
308 get() =
309 when (state) {
310 MotionControlState.Recording,
311 MotionControlState.Ended -> true
312 else -> false
313 }
314
315 val recordingEnded: Boolean
316 get() =
317 when (state) {
318 MotionControlState.Ended -> true
319 else -> false
320 }
321
322 fun nextFrame() {
323 composeTestRule.mainClock.advanceTimeByFrame()
324 composeTestRule.waitForIdle()
325
326 when (state) {
327 MotionControlState.Start -> {
328 delayReadyToPlayJob = motionControl.delayReadyToPlay.launch()
329 state = MotionControlState.WaitingToPlay
330 }
331 MotionControlState.WaitingToPlay -> {
332 if (delayReadyToPlayJob.isCompleted) {
333 delayRecordingJob = motionControl.delayRecording.launch()
334 state = MotionControlState.WaitingToRecord
335 }
336 }
337 MotionControlState.WaitingToRecord -> {
338 if (delayRecordingJob.isCompleted) {
339 recordingJob = motionControl.recording.launch()
340 state = MotionControlState.Recording
341 }
342 }
343 MotionControlState.Recording -> {
344 if (recordingJob.isCompleted) {
345 state = MotionControlState.Ended
346 }
347 }
348 MotionControlState.Ended -> {}
349 }
350
351 frameEmitter.tryEmit(composeTestRule.mainClock.currentTime)
352 testScope.runCurrent()
353
354 composeTestRule.waitForIdle()
355
356 if (state == MotionControlState.Recording && recordingJob.isCompleted) {
357 state = MotionControlState.Ended
358 }
359 }
360
361 override suspend fun awaitFrames(count: Int) {
362 // Since this is a state-flow, the current frame is counted too. This condition must wait
363 // for an additional frame to fulfill the contract
364 onFrame.take(count + 1).collect {}
365 }
366
367 override suspend fun awaitDelay(duration: Duration) {
368 val endTime = onFrame.value + duration.inWholeMilliseconds
369 onFrame.takeWhile { it < endTime }.collect {}
370 }
371
372 override suspend fun awaitCondition(check: () -> Boolean) {
373 onFrame.takeWhile { !check() }.collect {}
374 }
375
376 override suspend fun performTouchInputAsync(
377 onNode: SemanticsNodeInteraction,
378 gestureControl: TouchInjectionScope.() -> Unit
379 ) {
380 val node = onNode.fetchSemanticsNode()
381 val density = node.layoutInfo.density
382 val viewConfiguration = node.layoutInfo.viewConfiguration
383 val visibleSize =
384 with(node.boundsInRoot) { IntSize(width.roundToInt(), height.roundToInt()) }
385
386 val touchEventRecorder = TouchEventRecorder(density, viewConfiguration, visibleSize)
387 gestureControl(touchEventRecorder)
388
389 val recordedEntries = touchEventRecorder.recordedEntries
390 for (entry in recordedEntries) {
391 when (entry) {
392 is TouchEventRecorderEntry.AdvanceTime ->
393 awaitDelay(entry.durationMillis.milliseconds)
394 is TouchEventRecorderEntry.Cancel ->
395 onNode.performTouchInput { cancel(delayMillis = 0) }
396 is TouchEventRecorderEntry.Down ->
397 onNode.performTouchInput { down(entry.pointerId, entry.position) }
398 is TouchEventRecorderEntry.Move ->
399 onNode.performTouchInput { move(delayMillis = 0) }
400 is TouchEventRecorderEntry.Up -> onNode.performTouchInput { up(entry.pointerId) }
401 is TouchEventRecorderEntry.UpdatePointerTo ->
402 onNode.performTouchInput { updatePointerTo(entry.pointerId, entry.position) }
403 }
404 }
405 }
406
407 private fun MotionControlFn.launch(): Job {
408 val function = this
409 return testScope.launch { function() }
410 }
411 }
412
413 /** Records the invocations of the [TouchInjectionScope] methods. */
414 private sealed interface TouchEventRecorderEntry {
415
416 class AdvanceTime(val durationMillis: Long) : TouchEventRecorderEntry
417
418 object Cancel : TouchEventRecorderEntry
419
420 class Down(val pointerId: Int, val position: Offset) : TouchEventRecorderEntry
421
422 object Move : TouchEventRecorderEntry
423
424 class Up(val pointerId: Int) : TouchEventRecorderEntry
425
426 class UpdatePointerTo(val pointerId: Int, val position: Offset) : TouchEventRecorderEntry
427 }
428
429 private class TouchEventRecorder(
430 density: Density,
431 override val viewConfiguration: ViewConfiguration,
432 override val visibleSize: IntSize
<lambda>null433 ) : TouchInjectionScope, Density by density {
434
435 val lastPositions = mutableMapOf<Int, Offset>()
436 val recordedEntries = mutableListOf<TouchEventRecorderEntry>()
437
438 override fun advanceEventTime(durationMillis: Long) {
439 if (durationMillis > 0) {
440 recordedEntries.add(TouchEventRecorderEntry.AdvanceTime(durationMillis))
441 }
442 }
443
444 override fun cancel(delayMillis: Long) {
445 advanceEventTime(delayMillis)
446 recordedEntries.add(TouchEventRecorderEntry.Cancel)
447 }
448
449 override fun currentPosition(pointerId: Int): Offset? {
450 return lastPositions[pointerId]
451 }
452
453 override fun down(pointerId: Int, position: Offset) {
454 recordedEntries.add(TouchEventRecorderEntry.Down(pointerId, position))
455 lastPositions[pointerId] = position
456 }
457
458 override fun move(delayMillis: Long) {
459 advanceEventTime(delayMillis)
460 recordedEntries.add(TouchEventRecorderEntry.Move)
461 }
462
463 @ExperimentalTestApi
464 override fun moveWithHistoryMultiPointer(
465 relativeHistoricalTimes: List<Long>,
466 historicalCoordinates: List<List<Offset>>,
467 delayMillis: Long
468 ) {
469 TODO("Not yet supported")
470 }
471
472 override fun up(pointerId: Int) {
473 recordedEntries.add(TouchEventRecorderEntry.Up(pointerId))
474 lastPositions.remove(pointerId)
475 }
476
477 override fun updatePointerTo(pointerId: Int, position: Offset) {
478 recordedEntries.add(TouchEventRecorderEntry.UpdatePointerTo(pointerId, position))
479 lastPositions[pointerId] = position
480 }
481 }
482