• 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 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