1 /*
2  * Copyright 2022 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.screenshot
18 
19 import android.content.Context
20 import android.graphics.Bitmap
21 import android.graphics.Rect
22 import android.platform.uiautomator_helpers.DeviceHelpers.shell
23 import android.provider.Settings.System
24 import androidx.test.ext.junit.runners.AndroidJUnit4
25 import androidx.test.filters.MediumTest
26 import androidx.test.platform.app.InstrumentationRegistry
27 import com.google.common.truth.Truth.assertThat
28 import java.io.File
29 import java.lang.AssertionError
30 import java.util.ArrayList
31 import org.junit.After
32 import org.junit.AfterClass
33 import org.junit.BeforeClass
34 import org.junit.Rule
35 import org.junit.Test
36 import org.junit.runner.RunWith
37 import platform.test.screenshot.matchers.MSSIMMatcher
38 import platform.test.screenshot.matchers.PixelPerfectMatcher
39 import platform.test.screenshot.proto.ScreenshotResultProto.DiffResult
40 import platform.test.screenshot.report.DiffResultExportStrategy
41 import platform.test.screenshot.utils.loadBitmap
42 
43 class CustomGoldenPathManager(appcontext: Context, assetsPath: String = "assets") :
44     GoldenPathManager(appcontext, assetsPath) {
goldenIdentifierResolvernull45     override fun goldenIdentifierResolver(testName: String, extension: String): String =
46         "$testName.$extension"
47 }
48 
49 @RunWith(AndroidJUnit4::class)
50 @MediumTest
51 class ScreenshotTestRuleTest {
52 
53     private val fakeDiffEscrow = FakeDiffResultExport()
54 
55     @get:Rule
56     val rule =
57         ScreenshotTestRule(
58             CustomGoldenPathManager(InstrumentationRegistry.getInstrumentation().context),
59             diffEscrowStrategy = fakeDiffEscrow
60         )
61 
62     @Test
63     fun performDiff_sameBitmaps() {
64         val goldenIdentifier = "round_rect_gray"
65         val first = loadBitmap(goldenIdentifier)
66 
67         first.assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
68 
69         assertThat(fakeDiffEscrow.reports).isEmpty()
70     }
71 
72     @Test
73     fun performDiff_sameBitmaps_materialYouColors() {
74         val goldenIdentifier = "defaultClock_largeClock_regionSampledColor"
75         val first =
76             bitmapWithMaterialYouColorsSimulation(
77                 loadBitmap("defaultClock_largeClock_regionSampledColor_original"),
78                 /* isDarkTheme= */ true
79             )
80 
81         first.assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
82         assertThat(fakeDiffEscrow.reports).isEmpty()
83     }
84 
85     @Test
86     fun performDiff_noPixelCompared() {
87         val first = loadBitmap("round_rect_gray")
88         val regions = ArrayList<Rect>()
89         regions.add(Rect(/* left= */ 1, /* top= */ 1, /* right= */ 2, /* bottom=*/ 2))
90 
91         val goldenIdentifier = "round_rect_green"
92         first.assertAgainstGolden(
93             rule,
94             goldenIdentifier,
95             matcher = MSSIMMatcher(),
96             regions = regions
97         )
98 
99         assertThat(fakeDiffEscrow.reports).isEmpty()
100     }
101 
102     @Test
103     fun performDiff_sameRegion() {
104         val first = loadBitmap("qmc-folder1")
105         val startHeight = 18 * first.height / 20
106         val endHeight = 37 * first.height / 40
107         val startWidth = 10 * first.width / 20
108         val endWidth = 11 * first.width / 20
109         val matcher = MSSIMMatcher()
110         val regions = ArrayList<Rect>()
111         regions.add(Rect(startWidth, startHeight, endWidth, endHeight))
112         regions.add(Rect())
113 
114         val goldenIdentifier = "qmc-folder2"
115         first.assertAgainstGolden(rule, goldenIdentifier, matcher, regions)
116 
117         assertThat(fakeDiffEscrow.reports).isEmpty()
118     }
119 
120     @Test
121     fun performDiff_sameSizes_default_noMatch() {
122         val imageExtension = ".png"
123         val first = loadBitmap("round_rect_gray")
124         val compStatistics =
125             DiffResult.ComparisonStatistics.newBuilder()
126                 .setNumberPixelsCompared(1504)
127                 .setNumberPixelsDifferent(74)
128                 .setNumberPixelsIgnored(800)
129                 .setNumberPixelsSimilar(1430)
130                 .build()
131 
132         val goldenIdentifier = "round_rect_green"
133         expectErrorMessage("Image mismatch! Comparison stats: '$compStatistics'") {
134             first.assertAgainstGolden(rule, goldenIdentifier)
135         }
136 
137         assertThat(fakeDiffEscrow.reports)
138             .containsExactly(
139                 ReportedDiffResult(
140                     goldenIdentifier,
141                     DiffResult.Status.FAILED,
142                     hasExpected = true,
143                     hasDiff = true,
144                     comparisonStatistics = compStatistics
145                 )
146             )
147     }
148 
149     @Test
150     fun performDiff_sameSizes_pixelPerfect_noMatch() {
151         val first = loadBitmap("round_rect_gray")
152         val compStatistics =
153             DiffResult.ComparisonStatistics.newBuilder()
154                 .setNumberPixelsCompared(2304)
155                 .setNumberPixelsDifferent(556)
156                 .setNumberPixelsIdentical(1748)
157                 .build()
158 
159         val goldenIdentifier = "round_rect_green"
160         expectErrorMessage("Image mismatch! Comparison stats: '$compStatistics'") {
161             first.assertAgainstGolden(rule, goldenIdentifier, matcher = PixelPerfectMatcher())
162         }
163 
164         assertThat(fakeDiffEscrow.reports)
165             .containsExactly(
166                 ReportedDiffResult(
167                     goldenIdentifier,
168                     DiffResult.Status.FAILED,
169                     hasExpected = true,
170                     hasDiff = true,
171                     comparisonStatistics = compStatistics
172                 )
173             )
174     }
175 
176     @Test
177     fun performDiff_differentSizes() {
178         val first = loadBitmap("fullscreen_rect_gray")
179         val goldenIdentifier = "round_rect_gray"
180         val compStatistics =
181             DiffResult.ComparisonStatistics.newBuilder()
182                 .setNumberPixelsCompared(2304)
183                 .setNumberPixelsDifferent(568)
184                 .setNumberPixelsIdentical(1736)
185                 .build()
186 
187         expectErrorMessage(
188             "Sizes are different! Expected: [48, 48], Actual: [720, 1184]. Force aligned "
189                 + "at (0, 0). Comparison stats: '${compStatistics}'") {
190             first.assertAgainstGolden(rule, goldenIdentifier)
191         }
192 
193         assertThat(fakeDiffEscrow.reports)
194             .containsExactly(
195                 ReportedDiffResult(
196                     goldenIdentifier,
197                     DiffResult.Status.FAILED,
198                     hasExpected = true,
199                     hasDiff = true,
200                     comparisonStatistics = compStatistics
201                 )
202             )
203     }
204 
205     @Test(expected = IllegalArgumentException::class)
206     fun performDiff_incorrectGoldenName() {
207         val first = loadBitmap("fullscreen_rect_gray")
208 
209         first.assertAgainstGolden(rule, "round_rect_gray #")
210     }
211 
212     @Test
213     fun performDiff_missingGolden() {
214         val first = loadBitmap("round_rect_gray")
215 
216         val goldenIdentifier = "does_not_exist"
217 
218         expectErrorMessage(
219             "Missing golden image 'does_not_exist.png'. Did you mean to check in a new image?"
220         ) {
221             first.assertAgainstGolden(rule, goldenIdentifier)
222         }
223 
224         assertThat(fakeDiffEscrow.reports)
225             .containsExactly(
226                 ReportedDiffResult(
227                     goldenIdentifier,
228                     DiffResult.Status.MISSING_REFERENCE,
229                     hasExpected = false,
230                     hasDiff = false,
231                     comparisonStatistics = null
232                 ),
233             )
234     }
235 
236     @Test
237     fun screenshotAsserterHooks_successfulRun() {
238         var preRan = false
239         var postRan = false
240         val bitmap = loadBitmap("round_rect_green")
241         val asserter =
242             ScreenshotRuleAsserter.Builder(rule)
243                 .setOnBeforeScreenshot { preRan = true }
244                 .setOnAfterScreenshot { postRan = true }
245                 .setScreenshotProvider { bitmap }
246                 .build()
247         asserter.assertGoldenImage("round_rect_green")
248         assertThat(preRan).isTrue()
249         assertThat(postRan).isTrue()
250     }
251 
252     @Test
253     fun screenshotAsserterHooks_disablesVisibleDebugSettings() {
254         // Turn visual debug settings on
255         pointerLocationSetting = 1
256         showTouchesSetting = 1
257 
258         var preRan = false
259         val bitmap = loadBitmap("round_rect_green")
260         val asserter =
261             ScreenshotRuleAsserter.Builder(rule)
262                 .setOnBeforeScreenshot {
263                     preRan = true
264                     assertThat(pointerLocationSetting).isEqualTo(0)
265                     assertThat(showTouchesSetting).isEqualTo(0)
266                 }
267                 .setScreenshotProvider { bitmap }
268                 .build()
269         asserter.assertGoldenImage("round_rect_green")
270         assertThat(preRan).isTrue()
271 
272         // Clear visual debug settings
273         pointerLocationSetting = 0
274         showTouchesSetting = 0
275     }
276 
277     @Test
278     fun screenshotAsserterHooks_whenVisibleDebugSettingsOn_revertsSettings() {
279         // Turn visual debug settings on
280         pointerLocationSetting = 1
281         showTouchesSetting = 1
282 
283         val bitmap = loadBitmap("round_rect_green")
284         val asserter = ScreenshotRuleAsserter.Builder(rule).setScreenshotProvider { bitmap }.build()
285         asserter.assertGoldenImage("round_rect_green")
286         assertThat(pointerLocationSetting).isEqualTo(1)
287         assertThat(showTouchesSetting).isEqualTo(1)
288 
289         // Clear visual debug settings to pre-test values
290         pointerLocationSetting = 0
291         showTouchesSetting = 0
292     }
293 
294     @Test
295     fun screenshotAsserterHooks_whenVisibleDebugSettingsOff_retainsSettings() {
296         // Turn visual debug settings off
297         pointerLocationSetting = 0
298         showTouchesSetting = 0
299 
300         val bitmap = loadBitmap("round_rect_green")
301         val asserter = ScreenshotRuleAsserter.Builder(rule).setScreenshotProvider { bitmap }.build()
302         asserter.assertGoldenImage("round_rect_green")
303         assertThat(pointerLocationSetting).isEqualTo(0)
304         assertThat(showTouchesSetting).isEqualTo(0)
305     }
306 
307     @Test
308     fun screenshotAsserterHooks_assertionException() {
309         var preRan = false
310         var postRan = false
311         val bitmap = loadBitmap("round_rect_green")
312         val asserter =
313             ScreenshotRuleAsserter.Builder(rule)
314                 .setOnBeforeScreenshot { preRan = true }
315                 .setOnAfterScreenshot { postRan = true }
316                 .setScreenshotProvider {
317                     throw RuntimeException()
318                     bitmap
319                 }
320                 .build()
321         try {
322             asserter.assertGoldenImage("round_rect_green")
323         } catch (e: RuntimeException) {}
324         assertThat(preRan).isTrue()
325         assertThat(postRan).isTrue()
326     }
327 
328     @After
329     fun after() {
330         // Clear all files we generated so we don't have dependencies between tests
331         File(rule.goldenPathManager.deviceLocalPath).deleteRecursively()
332     }
333 
334     private fun expectErrorMessage(expectedErrorMessage: String, block: () -> Unit) {
335         try {
336             block()
337         } catch (e: AssertionError) {
338             val received = e.localizedMessage!!
339             assertThat(received).isEqualTo(expectedErrorMessage.trim())
340             return
341         }
342 
343         throw AssertionError("No AssertionError thrown!")
344     }
345 
346     data class ReportedDiffResult(
347         val goldenIdentifier: String,
348         val status: DiffResult.Status,
349         val hasExpected: Boolean = false,
350         val hasDiff: Boolean = false,
351         val comparisonStatistics: DiffResult.ComparisonStatistics? = null,
352     )
353 
354     class FakeDiffResultExport : DiffResultExportStrategy {
355         val reports = mutableListOf<ReportedDiffResult>()
356         override fun reportResult(
357             testIdentifier: String,
358             goldenIdentifier: String,
359             actual: Bitmap,
360             status: DiffResult.Status,
361             comparisonStatistics: DiffResult.ComparisonStatistics?,
362             expected: Bitmap?,
363             diff: Bitmap?
364         ) {
365             reports.add(
366                 ReportedDiffResult(
367                     goldenIdentifier,
368                     status,
369                     hasExpected = expected != null,
370                     hasDiff = diff != null,
371                     comparisonStatistics
372                 )
373             )
374         }
375     }
376     private companion object {
377         var prevPointerLocationSetting: Int = 0
378         var prevShowTouchesSetting: Int = 0
379 
380         private var pointerLocationSetting: Int
381             get() =
382                 shell("settings get system ${System.POINTER_LOCATION}").trim().toIntOrNull() ?: 0
383             set(value) {
384                 shell("settings put system ${System.POINTER_LOCATION} $value")
385             }
386 
387         private var showTouchesSetting
388             get() = shell("settings get system ${System.SHOW_TOUCHES}").trim().toIntOrNull() ?: 0
389             set(value) {
390                 shell("settings put system ${System.SHOW_TOUCHES} $value")
391             }
392 
393         @JvmStatic
394         @BeforeClass
395         fun setUpClass() {
396             prevPointerLocationSetting = pointerLocationSetting
397             prevShowTouchesSetting = showTouchesSetting
398         }
399 
400         @JvmStatic
401         @AfterClass
402         fun tearDownClass() {
403             pointerLocationSetting = prevPointerLocationSetting
404             showTouchesSetting = prevShowTouchesSetting
405         }
406     }
407 }
408