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