[2/3] Create a new motion testing toolkit based on AnimatorTestRule.
This toolkit will allow us to test all Animator and Spring-based motion
using AnimatorTestRule.
Bug: 323863002
Flag: EXEMPT test only
Test: atest AnimatorTestRuleToolkitTest
Change-Id: Id5eb04abe896d6d58920ff3eaf1867e401f2b5ee
diff --git a/tests/testables/Android.bp b/tests/testables/Android.bp
index f211185..17cc0b2 100644
--- a/tests/testables/Android.bp
+++ b/tests/testables/Android.bp
@@ -35,4 +35,8 @@
"androidx.test.rules",
"mockito-target-inline-minus-junit4",
],
+ static_libs: [
+ "PlatformMotionTesting",
+ "kotlinx_coroutines_test",
+ ],
}
diff --git a/tests/testables/src/android/animation/AnimatorTestRuleToolkit.kt b/tests/testables/src/android/animation/AnimatorTestRuleToolkit.kt
new file mode 100644
index 0000000..b27b826
--- /dev/null
+++ b/tests/testables/src/android/animation/AnimatorTestRuleToolkit.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.animation
+
+import android.animation.AnimatorTestRuleToolkit.Companion.TAG
+import android.util.Log
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import platform.test.motion.MotionTestRule
+import platform.test.motion.RecordedMotion
+import platform.test.motion.RecordedMotion.Companion.create
+import platform.test.motion.golden.DataPoint
+import platform.test.motion.golden.Feature
+import platform.test.motion.golden.FrameId
+import platform.test.motion.golden.TimeSeries
+import platform.test.motion.golden.TimeSeriesCaptureScope
+import platform.test.motion.golden.TimestampFrameId
+
+class AnimatorTestRuleToolkit(val animatorTestRule: AnimatorTestRule, val testScope: TestScope) {
+ internal companion object {
+ const val TAG = "AnimatorRuleToolkit"
+ }
+}
+
+/**
+ * Controls the timing of the motion recording.
+ *
+ * The time series is recorded while the [recording] function is running.
+ */
+class MotionControl(val recording: MotionControlFn)
+
+typealias MotionControlFn = suspend MotionControlScope.() -> Unit
+
+interface MotionControlScope {
+ /** Waits until [check] returns true. Invoked on each frame. */
+ suspend fun awaitCondition(check: () -> Boolean)
+
+ /** Waits for [count] frames to be processed. */
+ suspend fun awaitFrames(count: Int = 1)
+}
+
+/** Defines the sampling of features during a test run. */
+data class AnimatorRuleRecordingSpec<T>(
+ /** The root `observing` object, available in [timeSeriesCapture]'s [TimeSeriesCaptureScope]. */
+ val captureRoot: T,
+
+ /** The timing for the recording. */
+ val motionControl: MotionControl,
+
+ /** Time interval between frame captures, in milliseconds. */
+ val frameDurationMs: Long = 16L,
+
+ /** Produces the time-series, invoked on each animation frame. */
+ val timeSeriesCapture: TimeSeriesCaptureScope<T>.() -> Unit,
+)
+
+/** Records the time-series of the features specified in [recordingSpec]. */
+fun <T> MotionTestRule<AnimatorTestRuleToolkit>.recordMotion(
+ recordingSpec: AnimatorRuleRecordingSpec<T>,
+): RecordedMotion {
+ with(toolkit.animatorTestRule) {
+ val frameIdCollector = mutableListOf<FrameId>()
+ val propertyCollector = mutableMapOf<String, MutableList<DataPoint<*>>>()
+
+ fun recordFrame(frameId: FrameId) {
+ Log.i(TAG, "recordFrame($frameId)")
+ frameIdCollector.add(frameId)
+ recordingSpec.timeSeriesCapture.invoke(
+ TimeSeriesCaptureScope(recordingSpec.captureRoot, propertyCollector)
+ )
+ }
+
+ val motionControl =
+ MotionControlImpl(
+ toolkit.animatorTestRule,
+ toolkit.testScope,
+ recordingSpec.frameDurationMs,
+ recordingSpec.motionControl,
+ )
+
+ Log.i(TAG, "recordMotion() begin recording")
+
+ val startFrameTime = currentTime
+ while (!motionControl.recordingEnded) {
+ recordFrame(TimestampFrameId(currentTime - startFrameTime))
+ motionControl.nextFrame()
+ }
+
+ Log.i(TAG, "recordMotion() end recording")
+
+ val timeSeries =
+ TimeSeries(
+ frameIdCollector.toList(),
+ propertyCollector.entries.map { entry -> Feature(entry.key, entry.value) },
+ )
+
+ return create(timeSeries, null)
+ }
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+private class MotionControlImpl(
+ val animatorTestRule: AnimatorTestRule,
+ val testScope: TestScope,
+ val frameMs: Long,
+ motionControl: MotionControl,
+) : MotionControlScope {
+ private val recordingJob = motionControl.recording.launch()
+
+ private val frameEmitter = MutableStateFlow<Long>(0)
+ private val onFrame = frameEmitter.asStateFlow()
+
+ var recordingEnded: Boolean = false
+
+ fun nextFrame() {
+ animatorTestRule.advanceTimeBy(frameMs)
+
+ frameEmitter.tryEmit(animatorTestRule.currentTime)
+ testScope.runCurrent()
+
+ if (recordingJob.isCompleted) {
+ recordingEnded = true
+ }
+ }
+
+ override suspend fun awaitCondition(check: () -> Boolean) {
+ onFrame.takeWhile { !check() }.collect {}
+ }
+
+ override suspend fun awaitFrames(count: Int) {
+ onFrame.take(count).collect {}
+ }
+
+ private fun MotionControlFn.launch(): Job {
+ val function = this
+ return testScope.launch { function() }
+ }
+}
diff --git a/tests/testables/tests/Android.bp b/tests/testables/tests/Android.bp
index c23f41a..7110564 100644
--- a/tests/testables/tests/Android.bp
+++ b/tests/testables/tests/Android.bp
@@ -29,13 +29,17 @@
"src/**/*.kt",
"src/**/I*.aidl",
],
+ asset_dirs: ["goldens"],
resource_dirs: ["res"],
static_libs: [
+ "PlatformMotionTesting",
"androidx.core_core-animation",
"androidx.core_core-ktx",
+ "androidx.test.ext.junit",
"androidx.test.rules",
"androidx.test.ext.junit",
"hamcrest-library",
+ "kotlinx_coroutines_test",
"mockito-target-inline-minus-junit4",
"testables",
"truth",
diff --git a/tests/testables/tests/goldens/recordMotion_withAnimator.json b/tests/testables/tests/goldens/recordMotion_withAnimator.json
new file mode 100644
index 0000000..87fece5
--- /dev/null
+++ b/tests/testables/tests/goldens/recordMotion_withAnimator.json
@@ -0,0 +1,64 @@
+{
+ "frame_ids": [
+ 0,
+ 20,
+ 40,
+ 60,
+ 80,
+ 100,
+ 120,
+ 140,
+ 160,
+ 180,
+ 200,
+ 220,
+ 240,
+ 260,
+ 280,
+ 300,
+ 320,
+ 340,
+ 360,
+ 380,
+ 400,
+ 420,
+ 440,
+ 460,
+ 480,
+ 500
+ ],
+ "features": [
+ {
+ "name": "value",
+ "type": "float",
+ "data_points": [
+ 1,
+ 0.9960574,
+ 0.98429155,
+ 0.9648882,
+ 0.9381534,
+ 0.9045085,
+ 0.8644843,
+ 0.818712,
+ 0.76791346,
+ 0.7128896,
+ 0.65450853,
+ 0.5936906,
+ 0.5313952,
+ 0.46860474,
+ 0.40630943,
+ 0.34549147,
+ 0.2871104,
+ 0.23208654,
+ 0.181288,
+ 0.13551569,
+ 0.09549153,
+ 0.061846733,
+ 0.035111785,
+ 0.015708387,
+ 0.003942609,
+ 0
+ ]
+ }
+ ]
+}
diff --git a/tests/testables/tests/goldens/recordMotion_withSpring.json b/tests/testables/tests/goldens/recordMotion_withSpring.json
new file mode 100644
index 0000000..e9fb5b4
--- /dev/null
+++ b/tests/testables/tests/goldens/recordMotion_withSpring.json
@@ -0,0 +1,48 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272
+ ],
+ "features": [
+ {
+ "name": "value",
+ "type": "float",
+ "data_points": [
+ 1,
+ 0.9488604,
+ 0.83574325,
+ 0.7016156,
+ 0.5691678,
+ 0.4497436,
+ 0.34789434,
+ 0.26431116,
+ 0.19766562,
+ 0.14572789,
+ 0.10601636,
+ 0.076149896,
+ 0.05401709,
+ 0.037837274,
+ 0.026161024,
+ 0.017839976,
+ 0.011983856,
+ 0.007914998
+ ]
+ }
+ ]
+}
diff --git a/tests/testables/tests/src/android/animation/AnimatorTestRuleToolkitTest.kt b/tests/testables/tests/src/android/animation/AnimatorTestRuleToolkitTest.kt
new file mode 100644
index 0000000..fbef489
--- /dev/null
+++ b/tests/testables/tests/src/android/animation/AnimatorTestRuleToolkitTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.animation
+
+import android.util.FloatProperty
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.internal.dynamicanimation.animation.SpringAnimation
+import com.android.internal.dynamicanimation.animation.SpringForce
+import kotlinx.coroutines.test.TestScope
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.MotionTestRule
+import platform.test.motion.RecordedMotion
+import platform.test.motion.golden.FeatureCapture
+import platform.test.motion.golden.asDataPoint
+import platform.test.motion.testing.createGoldenPathManager
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class AnimatorTestRuleToolkitTest {
+ companion object {
+ private val GOLDEN_PATH_MANAGER =
+ createGoldenPathManager("frameworks/base/tests/testables/tests/goldens")
+
+ private val TEST_PROPERTY =
+ object : FloatProperty<TestState>("value") {
+ override fun get(state: TestState): Float {
+ return state.animatedValue
+ }
+
+ override fun setValue(state: TestState, value: Float) {
+ state.animatedValue = value
+ }
+ }
+ }
+
+ @get:Rule(order = 0) val animatorTestRule = AnimatorTestRule(this)
+ @get:Rule(order = 1)
+ val motionRule =
+ MotionTestRule(AnimatorTestRuleToolkit(animatorTestRule, TestScope()), GOLDEN_PATH_MANAGER)
+
+ @Test
+ fun recordMotion_withAnimator() {
+ val state = TestState()
+ AnimatorSet().apply {
+ duration = 500
+ play(
+ ValueAnimator.ofFloat(state.animatedValue, 0f).apply {
+ addUpdateListener { state.animatedValue = it.animatedValue as Float }
+ }
+ )
+ getInstrumentation().runOnMainSync { start() }
+ }
+
+ val recordedMotion =
+ record(state, MotionControl { awaitFrames(count = 26) }, sampleIntervalMs = 20L)
+
+ motionRule.assertThat(recordedMotion).timeSeriesMatchesGolden("recordMotion_withAnimator")
+ }
+
+ @Test
+ fun recordMotion_withSpring() {
+ val state = TestState()
+ var isDone = false
+ SpringAnimation(state, TEST_PROPERTY).apply {
+ spring =
+ SpringForce(0f).apply {
+ stiffness = 500f
+ dampingRatio = 0.95f
+ }
+
+ setStartValue(1f)
+ setMinValue(0f)
+ setMaxValue(1f)
+ minimumVisibleChange = 0.01f
+
+ addEndListener { _, _, _, _ -> isDone = true }
+ getInstrumentation().runOnMainSync { start() }
+ }
+
+ val recordedMotion =
+ record(state, MotionControl { awaitCondition { isDone } }, sampleIntervalMs = 16L)
+
+ motionRule.assertThat(recordedMotion).timeSeriesMatchesGolden("recordMotion_withSpring")
+ }
+
+ private fun record(
+ state: TestState,
+ motionControl: MotionControl,
+ sampleIntervalMs: Long,
+ ): RecordedMotion {
+ var recordedMotion: RecordedMotion? = null
+ getInstrumentation().runOnMainSync {
+ recordedMotion =
+ motionRule.recordMotion(
+ AnimatorRuleRecordingSpec(
+ state,
+ motionControl,
+ sampleIntervalMs,
+ ) {
+ feature(
+ FeatureCapture("value") { state -> state.animatedValue.asDataPoint() },
+ "value",
+ )
+ }
+ )
+ }
+ return recordedMotion!!
+ }
+
+ data class TestState(var animatedValue: Float = 1f)
+}