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))