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"
+ )
+ }
+}