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