Add prediction unit tests

Adding some unit tests to ensure that prediction works as expected.

Bug: 232941452
Test: gradlew :input:input-motionprediction:test
Change-Id: I469739735d08f0a4f1eddbc871703333d9d0d782
diff --git a/input/input-motionprediction/build.gradle b/input/input-motionprediction/build.gradle
index 08f5074..67426d2 100644
--- a/input/input-motionprediction/build.gradle
+++ b/input/input-motionprediction/build.gradle
@@ -19,6 +19,7 @@
 plugins {
     id("AndroidXPlugin")
     id("com.android.library")
+    id("org.jetbrains.kotlin.android")
 }
 
 dependencies {
@@ -26,11 +27,17 @@
 
     implementation("androidx.core:core:1.10.1")
 
-    androidTestImplementation(libs.testExtJunit)
-    androidTestImplementation(libs.testCore)
-    androidTestImplementation(libs.testRunner)
-    androidTestImplementation(libs.testRules)
-    androidTestImplementation(libs.espressoCore, excludes.espresso)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testCore)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRules)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+    testImplementation(libs.junit)
+    testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.kotlinReflect)
+
 }
 
 android {
diff --git a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java
index f091db8..f6841f7d 100644
--- a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java
+++ b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java
@@ -59,11 +59,13 @@
         int actionIndex = event.getActionIndex();
         int pointerId = event.getPointerId(actionIndex);
         if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
-            SinglePointerPredictor predictor = new SinglePointerPredictor();
+            SinglePointerPredictor predictor = new SinglePointerPredictor(
+                    pointerId,
+                    event.getToolType(actionIndex)
+            );
             if (mReportRateMs > 0) {
                 predictor.setReportRate(mReportRateMs);
             }
-            predictor.initStrokePrediction(pointerId, event.getToolType(actionIndex));
             predictor.onTouchEvent(event);
             mPredictorMap.put(pointerId, predictor);
         } else if (action == MotionEvent.ACTION_UP) {
diff --git a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
index ca64ac2..90eddf7 100644
--- a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
+++ b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
@@ -82,10 +82,10 @@
     private final DVector2 mJank = new DVector2();
 
     /* pointer of the gesture that requires prediction */
-    private int mPointerId = 0;
+    private int mPointerId;
 
     /* tool type of the gesture that requires prediction */
-    private int mToolType = MotionEvent.TOOL_TYPE_UNKNOWN;
+    private int mToolType;
 
     private double mPressure = 0;
     private double mLastOrientation = 0;
@@ -99,13 +99,7 @@
      * achieving close-to-zero latency, prediction errors can be more visible and the target should
      * be reduced to 20ms.
      */
-    public SinglePointerPredictor() {
-        mKalman.reset();
-        mPrevEventTime = 0;
-        mDownEventTime = 0;
-    }
-
-    void initStrokePrediction(int pointerId, int toolType) {
+    public SinglePointerPredictor(int pointerId, int toolType) {
         mKalman.reset();
         mPrevEventTime = 0;
         mDownEventTime = 0;
diff --git a/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/MotionEventGenerator.kt b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/MotionEventGenerator.kt
new file mode 100644
index 0000000..d7662eb
--- /dev/null
+++ b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/MotionEventGenerator.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2023 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 androidx.input.motionprediction
+
+import android.view.MotionEvent
+import androidx.test.core.view.MotionEventBuilder
+
+class MotionEventGenerator(val xGenerator: (Long) -> Float, val yGenerator: (Long) -> Float) {
+    private val downEventTime: Long = 0
+    private var currentEventTime: Long = downEventTime
+    private val startX = 500f
+    private val startY = 500f
+    private var sentDown = false
+
+    fun next(): MotionEvent {
+        val motionEventBuilder = MotionEventBuilder.newBuilder()
+            .setEventTime(currentEventTime)
+            .setDownTime(downEventTime)
+            .setActionIndex(0)
+
+        if (sentDown) {
+            motionEventBuilder.setAction(MotionEvent.ACTION_MOVE)
+        } else {
+            motionEventBuilder.setAction(MotionEvent.ACTION_DOWN)
+            sentDown = true
+        }
+
+        val pointerProperties = MotionEvent.PointerProperties()
+        pointerProperties.id = 0
+        pointerProperties.toolType = MotionEvent.TOOL_TYPE_STYLUS
+
+        val coords = MotionEvent.PointerCoords()
+        coords.x = startX + xGenerator(currentEventTime - downEventTime)
+        coords.y = startY + yGenerator(currentEventTime - downEventTime)
+        coords.pressure = 1f
+
+        motionEventBuilder.setPointer(pointerProperties, coords)
+
+        currentEventTime += MOTIONEVENT_RATE_MS
+        return motionEventBuilder.build()
+    }
+
+    fun getRateMs(): Long {
+        return MOTIONEVENT_RATE_MS
+    }
+}
+
+const val MOTIONEVENT_RATE_MS: Long = 5
diff --git a/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/kalman/SinglePointerPredictorTest.kt b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/kalman/SinglePointerPredictorTest.kt
new file mode 100644
index 0000000..f4a45a0
--- /dev/null
+++ b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/kalman/SinglePointerPredictorTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 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 androidx.input.motionprediction.kalman
+
+import android.view.MotionEvent
+import androidx.input.motionprediction.MotionEventGenerator
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.pow
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class SinglePointerPredictorTest {
+
+    @Test
+    fun simplePrediction() {
+        val generators = arrayOf(
+                // Constant
+                { _: Long -> 0f },
+                // Velocity
+                { delta: Long -> delta.toFloat() },
+                { delta: Long -> -delta.toFloat() },
+                { delta: Long -> 2 * delta.toFloat() },
+                // Acceleration
+                { delta: Long -> delta.toFloat().pow(2) / 4 },
+                { delta: Long -> -delta.toFloat().pow(2) / 4 },
+                { delta: Long -> delta.toFloat().pow(2) / 2 },
+                // Acceleration & velocity
+                { delta: Long -> delta.toFloat() + delta.toFloat().pow(2) / 4 },
+                { delta: Long -> -delta.toFloat() - delta.toFloat().pow(2) / 4 }
+        )
+        for ((xIndex, xGenerator) in generators.withIndex()) {
+            for ((yIndex, yGenerator) in generators.withIndex()) {
+                if (xIndex == 0 && yIndex == 0) {
+                    // Predictions won't be generated in this case
+                    continue
+                }
+                val predictor = constructPredictor()
+                val generator = MotionEventGenerator(xGenerator, yGenerator)
+                for (i in 1..INITIAL_FEED) {
+                    predictor.onTouchEvent(generator.next())
+                }
+                for (i in 1..PREDICT_LENGTH) {
+                    val predicted = predictor.predict(generator.getRateMs().toInt())!!
+                    val nextEvent = generator.next()
+                    assertThat(predicted.eventTime).isEqualTo(nextEvent.eventTime)
+                    assertThat(predicted.x).isWithin(0.5f).of(nextEvent.x)
+                    assertThat(predicted.y).isWithin(0.5f).of(nextEvent.y)
+
+                    predictor.onTouchEvent(nextEvent)
+                }
+            }
+        }
+    }
+}
+
+private fun constructPredictor(): SinglePointerPredictor = SinglePointerPredictor(
+        0,
+        MotionEvent.TOOL_TYPE_STYLUS
+)
+
+private const val INITIAL_FEED = 20
+private const val PREDICT_LENGTH = 10
diff --git a/input/input-motionprediction/src/test/resources/robolectric.properties b/input/input-motionprediction/src/test/resources/robolectric.properties
new file mode 100644
index 0000000..5566c22e
--- /dev/null
+++ b/input/input-motionprediction/src/test/resources/robolectric.properties
@@ -0,0 +1,3 @@
+# Robolectric currently doesn't support API 34, so we have to explicitly specify 33 as the target
+# sdk for now. Remove when no longer necessary.
+sdk=33
\ No newline at end of file