• 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 @file:OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
18 
19 package com.android.mechanics.testing
20 
21 import androidx.compose.runtime.LaunchedEffect
22 import androidx.compose.runtime.getValue
23 import androidx.compose.runtime.mutableFloatStateOf
24 import androidx.compose.runtime.setValue
25 import androidx.compose.ui.test.ExperimentalTestApi
26 import androidx.compose.ui.test.junit4.ComposeContentTestRule
27 import com.android.mechanics.DistanceGestureContext
28 import com.android.mechanics.MotionValue
29 import com.android.mechanics.debug.FrameData
30 import com.android.mechanics.spec.InputDirection
31 import com.android.mechanics.spec.MotionSpec
32 import kotlin.math.abs
33 import kotlin.math.floor
34 import kotlin.math.sign
35 import kotlinx.coroutines.ExperimentalCoroutinesApi
36 import kotlinx.coroutines.NonDisposableHandle.dispose
37 import kotlinx.coroutines.flow.MutableStateFlow
38 import kotlinx.coroutines.flow.StateFlow
39 import kotlinx.coroutines.flow.asStateFlow
40 import kotlinx.coroutines.flow.drop
41 import kotlinx.coroutines.flow.take
42 import kotlinx.coroutines.flow.takeWhile
43 import kotlinx.coroutines.launch
44 import kotlinx.coroutines.test.runCurrent
45 import kotlinx.coroutines.test.runTest
46 import platform.test.motion.MotionTestRule
47 import platform.test.motion.RecordedMotion.Companion.create
48 import platform.test.motion.golden.Feature
49 import platform.test.motion.golden.FrameId
50 import platform.test.motion.golden.TimeSeries
51 import platform.test.motion.golden.TimestampFrameId
52 import platform.test.motion.golden.ValueDataPoint
53 import platform.test.motion.golden.asDataPoint
54 
55 /** Toolkit to support [MotionValue] motion tests. */
56 class MotionValueToolkit(val composeTestRule: ComposeContentTestRule) {
57     companion object {
58 
59         val TimeSeries.input: List<Float>
60             get() = dataPoints("input")
61 
62         val TimeSeries.output: List<Float>
63             get() = dataPoints("output")
64 
65         val TimeSeries.outputTarget: List<Float>
66             get() = dataPoints("outputTarget")
67 
68         val TimeSeries.isStable: List<Boolean>
69             get() = dataPoints("isStable")
70 
71         internal const val TAG = "MotionValueToolkit"
72 
73         fun <T> TimeSeries.dataPoints(featureName: String): List<T> {
74             @Suppress("UNCHECKED_CAST")
75             return (features[featureName] as Feature<T>).dataPoints.map {
76                 require(it is ValueDataPoint)
77                 it.value
78             }
79         }
80     }
81 }
82 
83 interface InputScope {
84     val input: Float
85     val gestureContext: DistanceGestureContext
86     val underTest: MotionValue
87 
awaitStablenull88     suspend fun awaitStable()
89 
90     suspend fun awaitFrames(frames: Int = 1)
91 
92     var directionChangeSlop: Float
93 
94     fun updateValue(position: Float)
95 
96     suspend fun animateValueTo(
97         targetValue: Float,
98         changePerFrame: Float = abs(input - targetValue) / 5f,
99     )
100 
101     suspend fun animatedInputSequence(vararg values: Float)
102 
103     fun reset(position: Float, direction: InputDirection)
104 }
105 
106 enum class VerifyTimeSeriesResult {
107     SkipGoldenVerification,
108     AssertTimeSeriesMatchesGolden,
109 }
110 
MotionTestRulenull111 fun MotionTestRule<MotionValueToolkit>.goldenTest(
112     spec: MotionSpec,
113     createDerived: (underTest: MotionValue) -> List<MotionValue> = { emptyList() },
114     initialValue: Float = 0f,
115     initialDirection: InputDirection = InputDirection.Max,
116     directionChangeSlop: Float = 5f,
117     stableThreshold: Float = 0.01f,
<lambda>null118     verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult = {
119         VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden
120     },
121     testInput: suspend InputScope.() -> Unit,
<lambda>null122 ) = runTest {
123     with(toolkit.composeTestRule) {
124         val frameEmitter = MutableStateFlow<Long>(0)
125 
126         val testHarness =
127             MotionValueTestHarness(
128                 initialValue,
129                 initialDirection,
130                 spec,
131                 stableThreshold,
132                 directionChangeSlop,
133                 frameEmitter.asStateFlow(),
134                 createDerived,
135             )
136         val underTest = testHarness.underTest
137         val derived = testHarness.derived
138 
139         val inspectors = buildMap {
140             put(underTest, underTest.debugInspector())
141             derived.forEach { put(it, it.debugInspector()) }
142         }
143 
144         setContent {
145             LaunchedEffect(Unit) {
146                 launch { underTest.keepRunning() }
147                 derived.forEach { launch { it.keepRunning() } }
148             }
149         }
150 
151         val recordingJob = launch { testInput.invoke(testHarness) }
152 
153         waitForIdle()
154         mainClock.autoAdvance = false
155 
156         val frameIds = mutableListOf<FrameId>()
157         val frameData = mutableMapOf<MotionValue, MutableList<FrameData>>()
158 
159         fun recordFrame(frameId: TimestampFrameId) {
160             frameIds.add(frameId)
161             inspectors.forEach { (motionValue, inspector) ->
162                 frameData.computeIfAbsent(motionValue) { mutableListOf() }.add(inspector.frame)
163             }
164         }
165 
166         val startFrameTime = mainClock.currentTime
167         recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
168         while (!recordingJob.isCompleted) {
169             frameEmitter.tryEmit(mainClock.currentTime + 16)
170             runCurrent()
171             mainClock.advanceTimeByFrame()
172             recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
173         }
174 
175         val timeSeries =
176             TimeSeries(
177                 frameIds.toList(),
178                 buildList {
179                     frameData.forEach { (motionValue, frames) ->
180                         val prefix = if (motionValue == underTest) "" else "${motionValue.label}-"
181 
182                         add(Feature("${prefix}input", frames.map { it.input.asDataPoint() }))
183                         add(
184                             Feature(
185                                 "${prefix}gestureDirection",
186                                 frames.map { it.gestureDirection.name.asDataPoint() },
187                             )
188                         )
189                         add(Feature("${prefix}output", frames.map { it.output.asDataPoint() }))
190                         add(
191                             Feature(
192                                 "${prefix}outputTarget",
193                                 frames.map { it.outputTarget.asDataPoint() },
194                             )
195                         )
196                         add(
197                             Feature(
198                                 "${prefix}outputSpring",
199                                 frames.map { it.springParameters.asDataPoint() },
200                             )
201                         )
202                         add(Feature("${prefix}isStable", frames.map { it.isStable.asDataPoint() }))
203                     }
204                 },
205             )
206 
207         inspectors.values.forEach { it.dispose() }
208 
209         val recordedMotion = create(timeSeries, screenshots = null)
210         val skipGoldenVerification = verifyTimeSeries.invoke(recordedMotion.timeSeries)
211         if (skipGoldenVerification == VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden) {
212             assertThat(recordedMotion).timeSeriesMatchesGolden()
213         }
214     }
215 }
216 
217 private class MotionValueTestHarness(
218     initialInput: Float,
219     initialDirection: InputDirection,
220     spec: MotionSpec,
221     stableThreshold: Float,
222     directionChangeSlop: Float,
223     val onFrame: StateFlow<Long>,
224     createDerived: (underTest: MotionValue) -> List<MotionValue>,
225 ) : InputScope {
226 
227     override var input by mutableFloatStateOf(initialInput)
228     override val gestureContext: DistanceGestureContext =
229         DistanceGestureContext(initialInput, initialDirection, directionChangeSlop)
230 
231     override val underTest =
232         MotionValue(
<lambda>null233             { input },
234             gestureContext,
235             stableThreshold = stableThreshold,
236             initialSpec = spec,
237         )
238 
239     val derived = createDerived(underTest)
240 
updateValuenull241     override fun updateValue(position: Float) {
242         input = position
243         gestureContext.dragOffset = position
244     }
245 
246     override var directionChangeSlop: Float
247         get() = gestureContext.directionChangeSlop
248         set(value) {
249             gestureContext.directionChangeSlop = value
250         }
251 
awaitStablenull252     override suspend fun awaitStable() {
253         val debugInspectors = buildList {
254             add(underTest.debugInspector())
255             addAll(derived.map { it.debugInspector() })
256         }
257         try {
258 
259             onFrame
260                 // Since this is a state-flow, the current frame is counted too.
261                 .drop(1)
262                 .takeWhile { debugInspectors.any { !it.frame.isStable } }
263                 .collect {}
264         } finally {
265             debugInspectors.forEach { it.dispose() }
266         }
267     }
268 
awaitFramesnull269     override suspend fun awaitFrames(frames: Int) {
270         onFrame
271             // Since this is a state-flow, the current frame is counted too.
272             .drop(1)
273             .take(frames)
274             .collect {}
275     }
276 
animateValueTonull277     override suspend fun animateValueTo(targetValue: Float, changePerFrame: Float) {
278         require(changePerFrame > 0f)
279         var currentValue = input
280         val delta = targetValue - currentValue
281         val step = changePerFrame * delta.sign
282 
283         val stepCount = floor((abs(delta) / changePerFrame) - 1).toInt()
284         repeat(stepCount) {
285             currentValue += step
286             updateValue(currentValue)
287             awaitFrames()
288         }
289 
290         updateValue(targetValue)
291         awaitFrames()
292     }
293 
animatedInputSequencenull294     override suspend fun animatedInputSequence(vararg values: Float) {
295         values.forEach {
296             updateValue(it)
297             awaitFrames()
298         }
299     }
300 
resetnull301     override fun reset(position: Float, direction: InputDirection) {
302         input = position
303         gestureContext.reset(position, direction)
304     }
305 }
306