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