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