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                 ensureOutputDirectoryMarkerCreated()
76             }
77         }
78 
79     private val rule = extraRules.around(motionTestWatcher)
80 
81     override fun apply(base: Statement?, description: Description?): Statement =
82         rule.apply(base, description)
83 
84     private val scubaExportStrategy = ExportToScubaStrategy(goldenPathManager)
85 
86     /** Returns a Truth subject factory to be used with [Truth.assertAbout]. */
87     fun motion(): Subject.Factory<RecordedMotionSubject, RecordedMotion> {
88         return Subject.Factory { failureMetadata: FailureMetadata, subject: RecordedMotion? ->
89             RecordedMotionSubject(failureMetadata, subject, this)
90         }
91     }
92 
93     /** Shortcut for `Truth.assertAbout(motion()).that(recordedMotion)`. */
94     fun assertThat(recordedMotion: RecordedMotion): RecordedMotionSubject =
95         assertAbout(motion()).that(recordedMotion)
96 
97     /**
98      * Reads and parses the golden [TimeSeries].
99      *
100      * Golden data types not included in the `typeRegistry` will produce an [UnknownType].
101      *
102      * @param typeRegistry [DataPointType] implementations used to de-serialize structured JSON
103      *   values to golden values. See [TimeSeries.dataPointTypes] for creating the registry based on
104      *   the currently produced timeseries.
105      * @throws GoldenNotFoundException if the golden does not exist.
106      * @throws JSONException if the golden file fails to parse.
107      */
108     internal fun readGoldenTimeSeries(
109         goldenIdentifier: String,
110         typeRegistry: Map<String, DataPointType<*>>
111     ): TimeSeries {
112         val path = goldenPathManager.goldenIdentifierResolver(goldenIdentifier, JSON_EXTENSION)
113         try {
114             return goldenPathManager.appContext.assets.open(path).bufferedReader().use {
115                 val jsonObject = JSONObject(it.readText())
116                 JsonGoldenSerializer.fromJson(jsonObject, typeRegistry)
117             }
118         } catch (e: FileNotFoundException) {
119             throw GoldenNotFoundException(path)
120         }
121     }
122 
123     /** Writes generated, actual golden JSON data to the device, to be picked up by TF. */
124     internal fun writeGeneratedTimeSeries(
125         goldenIdentifier: String,
126         recordedMotion: RecordedMotion,
127         result: TimeSeriesVerificationResult,
128     ) {
129         requireValidGoldenIdentifier(goldenIdentifier)
130 
131         val relativeGoldenPath =
132             goldenPathManager.goldenIdentifierResolver(goldenIdentifier, JSON_EXTENSION)
133         val deviceLocalPath = File(goldenPathManager.deviceLocalPath)
134         val goldenFile =
135             deviceLocalPath.resolve(recordedMotion.testClassName).resolve(relativeGoldenPath)
136 
137         val goldenFileDirectory = checkNotNull(goldenFile.parentFile)
138         if (!goldenFileDirectory.exists()) {
139             goldenFileDirectory.mkdirs()
140         }
141 
142         val metadata = JSONObject()
143         metadata.put(
144             "goldenRepoPath",
145             "${goldenPathManager.assetsPathRelativeToBuildRoot}/$relativeGoldenPath"
146         )
147         metadata.put("filmstripTestIdentifier", debugFilmstripTestIdentifier(recordedMotion))
148         metadata.put("goldenIdentifier", goldenIdentifier)
149         metadata.put("result", result.name)
150 
151         recordedMotion.videoRenderer?.let { videoRenderer ->
152             try {
153                 val videoFile =
154                     goldenFile.resolveSibling("${goldenFile.nameWithoutExtension}.$VIDEO_EXTENSION")
155 
156                 videoRenderer.renderToFile(videoFile.absolutePath)
157                 metadata.put("videoLocation", videoFile.relativeTo(deviceLocalPath))
158             } catch (e: Exception) {
159                 Log.e(TAG, "Failed to render motion test video", e)
160             }
161         }
162 
163         try {
164             FileOutputStream(goldenFile).bufferedWriter().use {
165                 val jsonObject = JsonGoldenSerializer.toJson(recordedMotion.timeSeries)
166                 jsonObject.put("//metadata", metadata)
167                 it.write(jsonObject.toString(JSON_INDENTATION))
168             }
169         } catch (e: Exception) {
170             throw IOException("Failed to write generated JSON (${goldenFile.absolutePath}). ", e)
171         }
172     }
173 
174     private fun requireValidGoldenIdentifier(goldenIdentifier: String) {
175         require(goldenIdentifier.matches(GOLDEN_IDENTIFIER_REGEX)) {
176             "Golden identifier '$goldenIdentifier' does not satisfy the naming " +
177                 "requirement. Allowed characters are: '[A-Za-z0-9_-]'"
178         }
179     }
180 
181     /**
182      * The golden screenshot identifier used by []writeDebugFilmstrip]
183      *
184      * Allows tooling to recognize the debug filmstrip related to a motion test
185      */
186     private fun debugFilmstripTestIdentifier(
187         recordedMotion: RecordedMotion,
188     ) = "motion_debug_filmstrip_${recordedMotion.testClassName}"
189 
190     private fun ensureOutputDirectoryMarkerCreated() {
191         try {
192             val markerFile =
193                 File(goldenPathManager.deviceLocalPath).resolve(".motion_test_output_marker")
194             if (!markerFile.exists()) {
195                 markerFile.createNewFile()
196             }
197         } catch (e: IOException) {
198             Log.e(TAG, "Unable to create golden output marker file", e)
199         }
200     }
201 
202     companion object {
203         private const val JSON_EXTENSION = "json"
204         private const val VIDEO_EXTENSION = "mp4"
205         private const val JSON_INDENTATION = 2
206         private val GOLDEN_IDENTIFIER_REGEX = "^[A-Za-z0-9_-]+$".toRegex()
207         private const val TAG = "MotionTestRule"
208     }
209 }
210 
211 /**
212  * Time-series golden verification result.
213  *
214  * Note that downstream golden-update tooling relies on the exact naming of these enum values.
215  */
216 internal enum class TimeSeriesVerificationResult {
217     PASSED,
218     FAILED,
219     MISSING_REFERENCE
220 }
221 
222 class GoldenNotFoundException(val missingGoldenFile: String) : Exception()
223