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