MotionTestRule: Sampling specification for motion tests
Test: Unit tests
Bug: 322324387
Change-Id: Ib9bb34c7329be42f6dfc1bba693a437d1a0264ac
diff --git a/libraries/motion/src/platform/test/motion/Sampling.kt b/libraries/motion/src/platform/test/motion/Sampling.kt
new file mode 100644
index 0000000..d2efbca
--- /dev/null
+++ b/libraries/motion/src/platform/test/motion/Sampling.kt
@@ -0,0 +1,81 @@
+/*
+ * 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 platform.test.motion
+
+/**
+ * Description of animation sampling strategy.
+ *
+ * @param sampleAt The animation progress fractions at which to capture an animation frame.
+ * @param sampleBefore Samples the frame before the animation is started.
+ * @param sampleAfter Samples the frame after the animation has ended.
+ */
+data class Sampling(
+ val sampleAt: List<Float>,
+ val sampleBefore: Boolean = true,
+ val sampleAfter: Boolean = true,
+) {
+
+ init {
+ check(sampleAt.all { it in 0.0f..1.0f })
+ check(sampleAt.zipWithNext().all { it.first < it.second })
+ }
+
+ companion object {
+ /**
+ * Creates a [Sampling] to sample an animation exactly [sampleCount] times, evenly
+ * distributed over the animations playtime.
+ *
+ * [sampleAtStart] and [sampleAtEnd] define whether a frame is sampled at progress 0 and 1,
+ * respectively.
+ *
+ * [sampleBefore] and [sampleAfter] define whether to capture a sample before the animation
+ * is started, or after it is finished respectively. This is helpful to capture issues
+ * caused by code triggered at the start or end of the animation.
+ *
+ * [sampleAfter] is `false` by default, since [sampleAtEnd] in most cases equal to
+ * [sampleAfter], as the animation is automatically ended when the playTime of the animation
+ * equals the duration.
+ */
+ fun evenlySampled(
+ sampleCount: Int,
+ sampleBefore: Boolean = true,
+ sampleAtStart: Boolean = true,
+ sampleAtEnd: Boolean = true,
+ sampleAfter: Boolean = !sampleAtEnd,
+ ): Sampling {
+ if (sampleAtStart && sampleAtEnd) {
+ require(sampleCount >= 2)
+ } else {
+ require(sampleCount >= 1)
+ }
+
+ val offset = if (sampleAtStart) 0 else 1
+ val divider =
+ when {
+ sampleAtStart xor sampleAtEnd -> sampleCount
+ sampleAtStart and sampleAtEnd -> sampleCount - 1
+ else -> sampleCount + 1
+ }
+
+ return Sampling(
+ List(sampleCount) { (1f / divider) * (it + offset) },
+ sampleBefore,
+ sampleAfter,
+ )
+ }
+ }
+}
diff --git a/libraries/motion/tests/src/platform/test/motion/SamplingTest.kt b/libraries/motion/tests/src/platform/test/motion/SamplingTest.kt
new file mode 100644
index 0000000..ba8bbf4
--- /dev/null
+++ b/libraries/motion/tests/src/platform/test/motion/SamplingTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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 platform.test.motion
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Correspondence.tolerance
+import com.google.common.truth.IterableSubject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.Sampling.Companion.evenlySampled
+
+@RunWith(AndroidJUnit4::class)
+class SamplingTest {
+
+ @Test
+ fun evenlySampled_1_inMiddle() {
+ val subject = evenlySampled(1, sampleAtStart = false, sampleAtEnd = false)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(1 / 2f).inOrder()
+ }
+
+ @Test
+ fun evenlySampled_1_atStart() {
+ val subject = evenlySampled(1, sampleAtStart = true, sampleAtEnd = false)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(0f).inOrder()
+ }
+
+ @Test
+ fun evenlySampled_1_atEnd() {
+ val subject = evenlySampled(1, sampleAtStart = false, sampleAtEnd = true)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(1f).inOrder()
+ }
+
+ @Test
+ fun evenlySampled_1_atStartAndEnd_throws() {
+ assertThrows(IllegalArgumentException::class.java) {
+ evenlySampled(1, sampleAtStart = true, sampleAtEnd = true)
+ }
+ }
+
+ @Test
+ fun evenlySampled_2_inMiddle() {
+ val subject = evenlySampled(2, sampleAtStart = false, sampleAtEnd = false)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(1 / 3f, 2 / 3f).inOrder()
+ }
+
+ @Test
+ fun evenlySampled_2_atStart() {
+ val subject = evenlySampled(2, sampleAtStart = true, sampleAtEnd = false)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(0f, 1 / 2f).inOrder()
+ }
+
+ @Test
+ fun evenlySampled_2_atEnd() {
+ val subject = evenlySampled(2, sampleAtStart = false, sampleAtEnd = true)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(1 / 2f, 1f).inOrder()
+ }
+
+ @Test
+ fun evenlySampled_2_atStartAndEnd() {
+ val subject = evenlySampled(2, sampleAtStart = true, sampleAtEnd = true)
+ assertThat(subject.sampleAt).allowTolerance().containsExactly(0f, 1f).inOrder()
+ }
+}
+
+private fun IterableSubject.allowTolerance() = comparingElementsUsing(tolerance(1.0e-10))