• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package platform.test.motion
18 
19 import android.util.Log
20 import com.google.common.truth.FailureMetadata
21 import com.google.common.truth.Subject
22 import com.google.common.truth.Truth.assertAbout
23 import java.io.File
24 import java.io.FileNotFoundException
25 import java.io.FileOutputStream
26 import java.io.IOException
27 import kotlin.concurrent.Volatile
28 import org.json.JSONObject
29 import org.junit.rules.RuleChain
30 import org.junit.rules.TestRule
31 import org.junit.rules.TestWatcher
32 import org.junit.runner.Description
33 import org.junit.runners.model.Statement
34 import platform.test.motion.golden.DataPointType
35 import platform.test.motion.golden.JsonGoldenSerializer
36 import platform.test.motion.golden.TimeSeries
37 import platform.test.motion.truth.RecordedMotionSubject
38 import platform.test.screenshot.BitmapDiffer
39 import platform.test.screenshot.GoldenPathManager
40 import platform.test.screenshot.report.ExportToScubaStrategy
41 
42 /**
43  * Test rule to verify correctness of animations and other time-based state.
44  *
45  * Capture a time-series of values, at specified intervals, during an animation. Additionally, a
46  * screenshot is captured along each data frame, to simplify verification of the test setup as well
47  * as debugging.
48  *
49  * To capture the animation, use the [Toolkit]-provided extension functions. See for example
50  * `ComposeToolkit` and `ViewToolkit`.
51  *
52  * @param toolkit Environment specific implementation.
53  * @param goldenPathManager Specifies how to locate the golden files.
54  * @param bitmapDiffer A optional `ScreenshotTestRule` to enable support of `filmstripMatchesGolden`
55  */
56 class MotionTestRule<Toolkit>(
57     val toolkit: Toolkit,
58     private val goldenPathManager: GoldenPathManager,
59     internal val bitmapDiffer: BitmapDiffer? = null,
60     extraRules: RuleChain = RuleChain.emptyRuleChain(),
61 ) : TestRule {
62 
63     @Volatile internal var testClassName: String? = null
64     @Volatile internal var testMethodName: String? = null
65     private val motionTestWatcher =
66         object : TestWatcher() {
67             override fun starting(description: Description) {
68                 testClassName = description.testClass.simpleName
69                 testMethodName = description.methodName
70             }
71 
72             override fun finished(description: Description?) {
73                 testClassName = null
74                 testMethodName = null
75             }
76         }
77 
78     private val rule = extraRules.around(motionTestWatcher)
79 
80     override fun apply(base: Statement?, description: Description?): Statement =
81         rule.apply(base, description)
82 
83     private val scubaExportStrategy = ExportToScubaStrategy(goldenPathManager)
84 
85     /** Returns a Truth subject factory to be used with [Truth.assertAbout]. */
86     fun motion(): Subject.Factory<RecordedMotionSubject, RecordedMotion> {
87         return Subject.Factory { failureMetadata: FailureMetadata, subject: RecordedMotion? ->
88             RecordedMotionSubject(failureMetadata, subject, this)
89         }
90     }
91 
92     /** Shortcut for `Truth.assertAbout(motion()).that(recordedMotion)`. */
93     fun assertThat(recordedMotion: RecordedMotion): RecordedMotionSubject =
94         assertAbout(motion()).that(recordedMotion)
95 
96     /**
97      * Reads and parses the golden [TimeSeries].
98      *
99      * Golden data types not included in the `typeRegistry` will produce an [UnknownType].
100      *
101      * @param typeRegistry [DataPointType] implementations used to de-serialize structured JSON
102      *   values to golden values. See [TimeSeries.dataPointTypes] for creating the registry based on
103      *   the currently produced timeseries.
104      * @throws GoldenNotFoundException if the golden does not exist.
105      * @throws JSONException if the golden file fails to parse.
106      */
107     internal fun readGoldenTimeSeries(
108         goldenIdentifier: String,
109         typeRegistry: Map<String, DataPointType<*>>,
110     ): TimeSeries {
111         val path = goldenPathManager.goldenIdentifierResolver(goldenIdentifier, JSON_EXTENSION)
112         try {
113             return goldenPathManager.appContext.assets.open(path).bufferedReader().use {
114                 val jsonObject = JSONObject(it.readText())
115                 JsonGoldenSerializer.fromJson(jsonObject, typeRegistry)
116             }
117         } catch (e: FileNotFoundException) {
118             throw GoldenNotFoundException(path)
119         }
120     }
121 
122     /** Writes generated, actual golden JSON data to the device, to be picked up by TF. */
123     internal fun writeGeneratedTimeSeries(
124         goldenIdentifier: String,
125         recordedMotion: RecordedMotion,
126         result: TimeSeriesVerificationResult,
127     ) {
128         requireValidGoldenIdentifier(goldenIdentifier)
129         val relativeGoldenPath =
130             goldenPathManager.goldenIdentifierResolver(goldenIdentifier, JSON_ACTUAL_EXTENSION)
131         val goldenFilePath = getGoldenFilePath()
132         val goldenFile =
133             goldenFilePath.resolve(recordedMotion.testClassName).resolve(relativeGoldenPath)
134 
135         val goldenFileDirectory = checkNotNull(goldenFile.parentFile)
136         if (!goldenFileDirectory.exists()) {
137             goldenFileDirectory.mkdirs()
138         }
139 
140         val metadata = JSONObject()
141         metadata.put(
142             "goldenRepoPath",
143             "${goldenPathManager.assetsPathRelativeToBuildRoot}/${relativeGoldenPath.replace(
144                 JSON_ACTUAL_EXTENSION, JSON_EXTENSION,)}",
145         )
146         metadata.put("goldenIdentifier", goldenIdentifier)
147         metadata.put("testClassName", recordedMotion.testClassName)
148         metadata.put("testMethodName", recordedMotion.testMethodName)
149         metadata.put("deviceLocalPath", goldenFilePath)
150         metadata.put("result", result.name)
151 
152         recordedMotion.videoRenderer?.let { videoRenderer ->
153             try {
154                 val videoFile =
155                     goldenFile.resolveSibling("${goldenFile.nameWithoutExtension}.$VIDEO_EXTENSION")
156 
157                 videoRenderer.renderToFile(videoFile.absolutePath)
158                 metadata.put("videoLocation", videoFile.relativeTo(goldenFilePath))
159             } catch (e: Exception) {
160                 Log.e(TAG, "Failed to render motion test video", e)
161             }
162         }
163 
164         try {
165             FileOutputStream(goldenFile).bufferedWriter().use {
166                 val jsonObject = JsonGoldenSerializer.toJson(recordedMotion.timeSeries)
167                 jsonObject.put("//metadata", metadata)
168                 it.write(jsonObject.toString(JSON_INDENTATION))
169             }
170         } catch (e: Exception) {
171             throw IOException("Failed to write generated JSON (${goldenFile.absolutePath}). ", e)
172         }
173     }
174 
175     private fun getGoldenFilePath(): File {
176         return if (isRobolectricRuntime()) File("/tmp/motion")
177         else File(goldenPathManager.deviceLocalPath)
178     }
179 
180     private fun requireValidGoldenIdentifier(goldenIdentifier: String) {
181         require(goldenIdentifier.matches(GOLDEN_IDENTIFIER_REGEX)) {
182             "Golden identifier '$goldenIdentifier' does not satisfy the naming " +
183                 "requirement. Allowed characters are: '[A-Za-z0-9_-]'"
184         }
185     }
186 
187     companion object {
188         private const val JSON_EXTENSION = "json"
189         private const val JSON_ACTUAL_EXTENSION = "actual.${JSON_EXTENSION}"
190         private const val VIDEO_EXTENSION = "mp4"
191         private const val JSON_INDENTATION = 2
192         private val GOLDEN_IDENTIFIER_REGEX = "^[A-Za-z0-9_-]+$".toRegex()
193         private const val TAG = "MotionTestRule"
194 
195         fun isRobolectricRuntime(): Boolean {
196             return this::class.java.getClassLoader().javaClass.getName().contains("robolectric")
197         }
198     }
199 }
200 
201 /**
202  * Time-series golden verification result.
203  *
204  * Note that downstream golden-update tooling relies on the exact naming of these enum values.
205  */
206 internal enum class TimeSeriesVerificationResult {
207     PASSED,
208     FAILED,
209     MISSING_REFERENCE,
210 }
211 
212 class GoldenNotFoundException(val missingGoldenFile: String) : Exception()
213