MotionTestRule: Adding TimeSeriesSubject for diffing TimeSeries and producing a readable diff.

Updated the [TimeSeries] to expose the features via map instead, and
adding toString implementations for better readability of diff outputs

go/android-animation-tests-design

Test: Unit tests
Bug: 322324387
Change-Id: Ie22773d77d1d34917cc355380836f12c76897b6c
diff --git a/libraries/motion/src/platform/test/motion/golden/DataPoint.kt b/libraries/motion/src/platform/test/motion/golden/DataPoint.kt
index 1fa9542..528fa7b 100644
--- a/libraries/motion/src/platform/test/motion/golden/DataPoint.kt
+++ b/libraries/motion/src/platform/test/motion/golden/DataPoint.kt
@@ -56,6 +56,8 @@
  */
 data class ValueDataPoint<T>(val value: T & Any, val type: DataPointType<T>) : DataPoint<T> {
     override fun asJson() = type.toJson(this.value)
+
+    override fun toString(): String = "$value (${type.typeName})"
 }
 
 /**
@@ -75,6 +77,8 @@
             return jsonValue == JSONObject.NULL
         }
     }
+
+    override fun toString(): String = "null"
 }
 
 /**
@@ -89,6 +93,8 @@
 
     override fun asJson() = JSONObject().apply { put("type", "not_found") }
 
+    override fun toString(): String = "{{not_found}}"
+
     companion object {
         internal val instance = NotFoundDataPoint<Any>()
 
@@ -103,11 +109,9 @@
 
     override fun asJson() = throw JSONException("Feature must not contain UnknownDataPoints")
 
-    companion object {
-        fun isUnknownValue(jsonValue: Any): Boolean {
-            return jsonValue is JSONObject && "not_found" == jsonValue.getString("unknown")
-        }
+    override fun toString(): String = "{{unknown_type}}"
 
+    companion object {
         internal val instance = UnknownType<Any>()
     }
 }
diff --git a/libraries/motion/src/platform/test/motion/golden/DataPointType.kt b/libraries/motion/src/platform/test/motion/golden/DataPointType.kt
index 07e3d3b..d1d3c7d 100644
--- a/libraries/motion/src/platform/test/motion/golden/DataPointType.kt
+++ b/libraries/motion/src/platform/test/motion/golden/DataPointType.kt
@@ -46,6 +46,10 @@
     }
 
     fun toJson(value: T): Any = valueToJson(value)
+
+    override fun toString(): String {
+        return typeName
+    }
 }
 
 /** Signals that a JSON value cannot be deserialized by a [DataPointType]. */
diff --git a/libraries/motion/src/platform/test/motion/golden/JsonGoldenSerializer.kt b/libraries/motion/src/platform/test/motion/golden/JsonGoldenSerializer.kt
index f0ed95e..1f2c635 100644
--- a/libraries/motion/src/platform/test/motion/golden/JsonGoldenSerializer.kt
+++ b/libraries/motion/src/platform/test/motion/golden/JsonGoldenSerializer.kt
@@ -67,7 +67,7 @@
             )
             put(
                 KEY_FEATURES,
-                JSONArray().apply { golden.features.map(::featureToJson).forEach(this::put) }
+                JSONArray().apply { golden.features.values.map(::featureToJson).forEach(this::put) }
             )
         }
 
diff --git a/libraries/motion/src/platform/test/motion/golden/TimeSeries.kt b/libraries/motion/src/platform/test/motion/golden/TimeSeries.kt
index daa4796..d9d19a9 100644
--- a/libraries/motion/src/platform/test/motion/golden/TimeSeries.kt
+++ b/libraries/motion/src/platform/test/motion/golden/TimeSeries.kt
@@ -22,11 +22,20 @@
  * A [TimeSeries] contains a number of distinct [Feature]s, each of which must contain exactly one
  * [DataPoint] per [frameIds], in the same order.
  */
-data class TimeSeries(val frameIds: List<FrameId>, val features: List<Feature<*>>) {
+data class TimeSeries(val frameIds: List<FrameId>, val features: Map<String, Feature<*>>) {
+
+    constructor(
+        frameIds: List<FrameId>,
+        features: List<Feature<*>>
+    ) : this(frameIds, features.associateBy { it.name }) {
+        require(features.size == this.features.size) { "duplicate feature names" }
+    }
+
     init {
-        features.forEach {
-            require(it.dataPoints.size == frameIds.size) {
-                "Feature [${it.name}] includes ${it.dataPoints.size} data points, " +
+        features.forEach { (key, feature) ->
+            require(key == feature.name)
+            require(feature.dataPoints.size == frameIds.size) {
+                "Feature [$key] includes ${feature.dataPoints.size} data points, " +
                     "but ${frameIds.size} data points are expected"
             }
         }
diff --git a/libraries/motion/src/platform/test/motion/truth/TimeSeriesSubject.kt b/libraries/motion/src/platform/test/motion/truth/TimeSeriesSubject.kt
new file mode 100644
index 0000000..992d80c
--- /dev/null
+++ b/libraries/motion/src/platform/test/motion/truth/TimeSeriesSubject.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.truth
+
+import com.google.common.truth.Fact
+import com.google.common.truth.Fact.fact
+import com.google.common.truth.Fact.simpleFact
+import com.google.common.truth.FailureMetadata
+import com.google.common.truth.Subject
+import com.google.common.truth.Subject.Factory
+import com.google.common.truth.Truth
+import platform.test.motion.golden.TimeSeries
+
+/** Subject on [TimeSeries] to produce meaningful failure diffs. */
+class TimeSeriesSubject
+private constructor(failureMetadata: FailureMetadata, private val actual: TimeSeries?) :
+    Subject(failureMetadata, actual) {
+
+    override fun isEqualTo(expected: Any?) {
+        if (actual is TimeSeries && expected is TimeSeries) {
+            val facts = compareTimeSeries(expected, actual)
+            if (facts.isNotEmpty()) {
+                failWithoutActual(facts[0], *(facts.drop(1)).toTypedArray())
+            }
+        } else {
+            super.isEqualTo(expected)
+        }
+    }
+
+    private fun compareTimeSeries(expected: TimeSeries, actual: TimeSeries) =
+        buildList<Fact> {
+            val actualToExpectedDataPointIndices: List<Pair<Int, Int>>
+            if (actual.frameIds != expected.frameIds) {
+                add(simpleFact("TimeSeries.frames does not match"))
+                add(fact("|  expected", expected.frameIds.map { it.label }))
+                add(fact("|  but got", actual.frameIds.map { it.label }))
+
+                val actualFrameIds = actual.frameIds.toSet()
+                val expectedFrameIds = expected.frameIds.toSet()
+                val framesToCompare = actualFrameIds.intersect(expectedFrameIds)
+
+                if (framesToCompare != actualFrameIds) {
+                    val unexpected = actualFrameIds - framesToCompare
+                    add(fact("|  unexpected (${ unexpected.size})", unexpected.map { it.label }))
+                }
+
+                if (framesToCompare != expectedFrameIds) {
+                    val missing = expectedFrameIds - framesToCompare
+                    add(fact("|  missing (${ missing.size})", missing.map { it.label }))
+                }
+                actualToExpectedDataPointIndices =
+                    framesToCompare.map {
+                        actual.frameIds.indexOf(it) to expected.frameIds.indexOf(it)
+                    }
+            } else {
+                actualToExpectedDataPointIndices = List(actual.frameIds.size) { it to it }
+            }
+
+            val featuresToCompare: Set<String>
+            if (actual.features.keys != expected.features.keys) {
+                featuresToCompare = actual.features.keys.intersect(expected.features.keys)
+                add(simpleFact("TimeSeries.features does not match"))
+
+                if (featuresToCompare != actual.features.keys) {
+                    val unexpected = actual.features.keys - featuresToCompare
+                    add(fact("|  unexpected (${ unexpected.size})", unexpected))
+                }
+
+                if (featuresToCompare != expected.features.keys) {
+                    val missing = expected.features.keys - featuresToCompare
+                    add(fact("|  missing (${ missing.size})", missing))
+                }
+            } else {
+                featuresToCompare = actual.features.keys
+            }
+
+            featuresToCompare.forEach { featureKey ->
+                val actualFeature = checkNotNull(actual.features[featureKey])
+                val expectedFeature = checkNotNull(expected.features[featureKey])
+
+                val mismatchingDataPointIndices =
+                    actualToExpectedDataPointIndices.filter { (actualIndex, expectedIndex) ->
+                        actualFeature.dataPoints[actualIndex] !=
+                            expectedFeature.dataPoints[expectedIndex]
+                    }
+
+                if (mismatchingDataPointIndices.isNotEmpty()) {
+                    add(simpleFact("TimeSeries.features[$featureKey].dataPoints do not match"))
+
+                    mismatchingDataPointIndices.forEach { (actualIndex, expectedIndex) ->
+                        add(simpleFact("|  @${actual.frameIds[actualIndex].label}"))
+                        add(fact("|    expected", actualFeature.dataPoints[actualIndex]))
+                        add(fact("|    but was", expectedFeature.dataPoints[expectedIndex]))
+                    }
+                }
+            }
+        }
+
+    companion object {
+        /** Returns a factory to be used with [Truth.assertAbout]. */
+        fun timeSeries(): Factory<TimeSeriesSubject, TimeSeries> {
+            return Factory { failureMetadata: FailureMetadata, subject: TimeSeries? ->
+                TimeSeriesSubject(failureMetadata, subject)
+            }
+        }
+
+        /** Shortcut for `Truth.assertAbout(timeSeries()).that(timeSeries)`. */
+        fun assertThat(timeSeries: TimeSeries): TimeSeriesSubject =
+            Truth.assertAbout(timeSeries()).that(timeSeries)
+    }
+}
diff --git a/libraries/motion/tests/src/platform/test/motion/golden/DataPointTypeTest.kt b/libraries/motion/tests/src/platform/test/motion/golden/DataPointTypeTest.kt
index 594e4a9..097a82f 100644
--- a/libraries/motion/tests/src/platform/test/motion/golden/DataPointTypeTest.kt
+++ b/libraries/motion/tests/src/platform/test/motion/golden/DataPointTypeTest.kt
@@ -49,11 +49,12 @@
 
     @Test
     fun makeDataPoint_ofInstance_createsValueDataPoint() {
-        val dataPoint = subject.makeDataPoint(Native("one"))
+        val nativeValue = Native("one")
+        val dataPoint = subject.makeDataPoint(nativeValue)
 
         assertThat(dataPoint).isInstanceOf(ValueDataPoint::class.java)
         val valueDataPoint = dataPoint as ValueDataPoint
-        assertThat(valueDataPoint.value).isSameInstanceAs(Native("one"))
+        assertThat(valueDataPoint.value).isSameInstanceAs(nativeValue)
         assertThat(valueDataPoint.type).isSameInstanceAs(subject)
     }
 
diff --git a/libraries/motion/tests/src/platform/test/motion/truth/TimeSeriesSubjectTest.kt b/libraries/motion/tests/src/platform/test/motion/truth/TimeSeriesSubjectTest.kt
new file mode 100644
index 0000000..3206cd1
--- /dev/null
+++ b/libraries/motion/tests/src/platform/test/motion/truth/TimeSeriesSubjectTest.kt
@@ -0,0 +1,195 @@
+/*
+ * 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.truth
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Correspondence
+import com.google.common.truth.ExpectFailure
+import com.google.common.truth.TruthFailureSubject
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.golden.Feature
+import platform.test.motion.golden.SupplementalFrameId
+import platform.test.motion.golden.TimeSeries
+import platform.test.motion.golden.TimestampFrameId
+import platform.test.motion.golden.asDataPoint
+import platform.test.motion.truth.TimeSeriesSubject.Companion.assertThat
+
+@RunWith(AndroidJUnit4::class)
+class TimeSeriesSubjectTest {
+
+    @Test
+    fun isEqualTo_nonTimeSeriesObject_usesDefaultImplementation() {
+        with(assertThrows { assertThat(TimeSeries(listOf(), emptyList())).isEqualTo("foo") }) {
+            factValue("expected").isEqualTo("foo")
+            factValue("but was").isEqualTo("TimeSeries(frameIds=[], features={})")
+        }
+    }
+
+    @Test
+    fun isEqualTo_matchingTimeSeries() {
+        val timeSeries = createTimeSeries(2)
+
+        assertThat(timeSeries).isEqualTo(timeSeries.copy())
+    }
+
+    @Test
+    fun isEqualTo_actualHasDifferentFrameTimes() {
+        val expected = createTimeSeries(2)
+        val actual =
+            expected.copy(frameIds = listOf(expected.frameIds[0], SupplementalFrameId("x")))
+
+        with(assertThrows { assertThat(actual).isEqualTo(expected) }) {
+            factKeys().contains("TimeSeries.frames does not match")
+            factValue("|  expected").isEqualTo("[0ms, 1ms]")
+            factValue("|  but got").isEqualTo("[0ms, x]")
+            factValue("|  unexpected (1)").isEqualTo("[x]")
+            factValue("|  missing (1)").isEqualTo("[1ms]")
+
+            factKeys().comparingElementsUsing(startsWith).doesNotContain("TimeSeries.features")
+        }
+    }
+
+    @Test
+    fun isEqualTo_missingFrame() {
+        val expected = createTimeSeries(3)
+        val actual = createTimeSeries(2)
+
+        with(assertThrows { assertThat(actual).isEqualTo(expected) }) {
+            factKeys().contains("TimeSeries.frames does not match")
+
+            factValue("|  expected").isEqualTo("[0ms, 1ms, 2ms]")
+            factValue("|  but got").isEqualTo("[0ms, 1ms]")
+            factValue("|  missing (1)").isEqualTo("[2ms]")
+
+            factKeys().comparingElementsUsing(startsWith).doesNotContain("TimeSeries.features")
+        }
+    }
+
+    @Test
+    fun isEqualTo_additionalFrame() {
+        val expected = createTimeSeries(1)
+        val actual = createTimeSeries(2)
+
+        with(assertThrows { assertThat(actual).isEqualTo(expected) }) {
+            factKeys().contains("TimeSeries.frames does not match")
+
+            factValue("|  expected").isEqualTo("[0ms]")
+            factValue("|  but got").isEqualTo("[0ms, 1ms]")
+            factValue("|  unexpected (1)").isEqualTo("[1ms]")
+
+            factKeys().comparingElementsUsing(startsWith).doesNotContain("TimeSeries.features")
+        }
+    }
+
+    @Test
+    fun isEqualTo_missingFeature() {
+        val expected = createTimeSeries(2)
+        val actual =
+            expected.copy(
+                features =
+                    buildMap {
+                        putAll(expected.features)
+                        remove("bar")
+                    }
+            )
+
+        with(assertThrows { assertThat(actual).isEqualTo(expected) }) {
+            factKeys().contains("TimeSeries.features does not match")
+
+            factValue("|  missing (1)").isEqualTo("[bar]")
+        }
+    }
+
+    @Test
+    fun isEqualTo_additionalFeature() {
+        val expected = createTimeSeries(2)
+        val actual =
+            expected.copy(
+                features =
+                    buildMap {
+                        putAll(expected.features)
+                        put("baz", createIntFeature("baz", 2))
+                    }
+            )
+
+        with(assertThrows { assertThat(actual).isEqualTo(expected) }) {
+            factKeys().contains("TimeSeries.features does not match")
+
+            factValue("|  unexpected (1)").isEqualTo("[baz]")
+        }
+    }
+
+    @Test
+    fun isEqualTo_actualHasDifferentDataPoint() {
+        val expected =
+            TimeSeries(
+                createFrames(2),
+                listOf(Feature("foo", listOf(1.asDataPoint(), 2.asDataPoint())))
+            )
+        val actual =
+            TimeSeries(
+                createFrames(2),
+                listOf(Feature("foo", listOf(1.asDataPoint(), 3.asDataPoint())))
+            )
+
+        with(assertThrows { assertThat(actual).isEqualTo(expected) }) {
+            factKeys()
+                .containsExactly(
+                    "TimeSeries.features[foo].dataPoints do not match",
+                    "|  @1ms",
+                    "|    expected",
+                    "|    but was"
+                )
+                .inOrder()
+
+            factValue("|    expected").isEqualTo("3 (int)")
+            factValue("|    but was").isEqualTo("2 (int)")
+        }
+    }
+
+    private fun createTimeSeries(frameCount: Int) =
+        TimeSeries(
+            createFrames(frameCount),
+            listOf(createIntFeature("foo", frameCount), createStringFeature("bar", frameCount))
+        )
+
+    private fun createFrames(frameCount: Int) = List(frameCount) { TimestampFrameId(it.toLong()) }
+    private fun createIntFeature(name: String, dataPoints: Int) =
+        Feature(name, List(dataPoints) { it.asDataPoint() })
+    private fun createStringFeature(name: String, dataPoints: Int) =
+        Feature(name, List(dataPoints) { it.toString().asDataPoint() })
+
+    private inline fun assertThrows(body: () -> Unit): TruthFailureSubject {
+        try {
+            body()
+        } catch (e: Throwable) {
+            if (e is AssertionError) {
+                return ExpectFailure.assertThat(e)
+            }
+            throw e
+        }
+        throw AssertionError("Body completed successfully. Expected AssertionError")
+    }
+    companion object {
+        val startsWith: Correspondence<String, String> =
+            Correspondence.from(
+                { actual, expected -> checkNotNull(actual).startsWith(checkNotNull(expected)) },
+                "starts with"
+            )
+    }
+}