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