1 /*
<lambda>null2  * Copyright 2021 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 package androidx.wear.compose.material
17 
18 import android.graphics.Bitmap
19 import android.os.Build
20 import android.util.Log
21 import androidx.annotation.RequiresApi
22 import androidx.compose.foundation.Image
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.BoxScope
25 import androidx.compose.foundation.layout.fillMaxSize
26 import androidx.compose.foundation.layout.sizeIn
27 import androidx.compose.material.icons.Icons
28 import androidx.compose.material.icons.outlined.Add
29 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.CompositionLocalProvider
31 import androidx.compose.testutils.assertAgainstGolden
32 import androidx.compose.ui.Alignment
33 import androidx.compose.ui.Modifier
34 import androidx.compose.ui.geometry.Rect
35 import androidx.compose.ui.graphics.Color
36 import androidx.compose.ui.graphics.ImageBitmap
37 import androidx.compose.ui.graphics.asAndroidBitmap
38 import androidx.compose.ui.graphics.toPixelMap
39 import androidx.compose.ui.layout.ContentScale
40 import androidx.compose.ui.platform.LocalLayoutDirection
41 import androidx.compose.ui.platform.testTag
42 import androidx.compose.ui.semantics.SemanticsActions
43 import androidx.compose.ui.semantics.SemanticsNode
44 import androidx.compose.ui.test.SemanticsNodeInteraction
45 import androidx.compose.ui.test.captureToImage
46 import androidx.compose.ui.test.junit4.ComposeContentTestRule
47 import androidx.compose.ui.test.onNodeWithTag
48 import androidx.compose.ui.test.onNodeWithText
49 import androidx.compose.ui.test.performSemanticsAction
50 import androidx.compose.ui.text.TextLayoutResult
51 import androidx.compose.ui.text.TextStyle
52 import androidx.compose.ui.unit.Dp
53 import androidx.compose.ui.unit.DpRect
54 import androidx.compose.ui.unit.LayoutDirection
55 import androidx.compose.ui.unit.dp
56 import androidx.compose.ui.unit.height
57 import androidx.compose.ui.unit.isUnspecified
58 import androidx.compose.ui.unit.toSize
59 import androidx.test.platform.app.InstrumentationRegistry
60 import androidx.test.screenshot.AndroidXScreenshotTestRule
61 import java.io.File
62 import java.io.FileOutputStream
63 import java.io.IOException
64 import kotlin.math.abs
65 import kotlin.math.round
66 import org.junit.Assert
67 
68 /** Constant to emulate very big but finite constraints */
69 val BigTestMaxWidth = 5000.dp
70 val BigTestMaxHeight = 5000.dp
71 
72 internal const val TEST_TAG = "test-item"
73 
74 fun ComposeContentTestRule.setContentWithTheme(
75     modifier: Modifier = Modifier,
76     composable: @Composable BoxScope.() -> Unit
77 ) {
78     setContent { MaterialTheme { Box(modifier = modifier, content = composable) } }
79 }
80 
ComposeContentTestRulenull81 fun ComposeContentTestRule.setContentWithThemeForSizeAssertions(
82     parentMaxWidth: Dp = BigTestMaxWidth,
83     parentMaxHeight: Dp = BigTestMaxHeight,
84     useUnmergedTree: Boolean = false,
85     content: @Composable () -> Unit
86 ): SemanticsNodeInteraction {
87     setContent {
88         MaterialTheme {
89             Box {
90                 Box(
91                     Modifier.sizeIn(maxWidth = parentMaxWidth, maxHeight = parentMaxHeight)
92                         .testTag("containerForSizeAssertion")
93                 ) {
94                     content()
95                 }
96             }
97         }
98     }
99 
100     return onNodeWithTag("containerForSizeAssertion", useUnmergedTree = useUnmergedTree)
101 }
102 
textStyleOfnull103 fun ComposeContentTestRule.textStyleOf(text: String): TextStyle {
104     val textLayoutResults = mutableListOf<TextLayoutResult>()
105     onNodeWithText(text, useUnmergedTree = true).performSemanticsAction(
106         SemanticsActions.GetTextLayoutResult
107     ) {
108         it(textLayoutResults)
109     }
110     return textLayoutResults[0].layoutInput.style
111 }
112 
assertTextTypographyEqualsnull113 fun assertTextTypographyEquals(expectedStyle: TextStyle, actualStyle: TextStyle) {
114     Assert.assertEquals(expectedStyle.fontSize, actualStyle.fontSize)
115     Assert.assertEquals(expectedStyle.fontFamily, actualStyle.fontFamily)
116     Assert.assertEquals(expectedStyle.letterSpacing, actualStyle.letterSpacing)
117     Assert.assertEquals(expectedStyle.fontWeight, actualStyle.fontWeight)
118     Assert.assertEquals(expectedStyle.lineHeight, actualStyle.lineHeight)
119 }
120 
121 @Composable
TestImagenull122 fun TestImage(iconLabel: String = "TestIcon", modifier: Modifier = Modifier) {
123     val testImage = Icons.Outlined.Add
124     Image(
125         testImage,
126         iconLabel,
127         modifier = modifier.fillMaxSize().testTag(iconLabel),
128         contentScale = ContentScale.Fit,
129         alignment = Alignment.Center
130     )
131 }
132 
133 @Composable
TestIconnull134 fun TestIcon(modifier: Modifier = Modifier, iconLabel: String = "TestIcon") {
135     val testImage = Icons.Outlined.Add
136     Icon(
137         imageVector = testImage,
138         contentDescription = iconLabel,
139         modifier = modifier.testTag(iconLabel)
140     )
141 }
142 
143 /**
144  * assertContainsColor - uses a threshold on an ImageBitmap's color distribution to check that a UI
145  * element is predominantly the expected color.
146  */
assertContainsColornull147 fun ImageBitmap.assertContainsColor(expectedColor: Color, minPercent: Float = 50.0f) {
148     val histogram = histogram()
149     if (!histogram.containsKey(expectedColor)) {
150         throw AssertionError("Expected color $expectedColor was not found in the bitmap.")
151     }
152 
153     val actualPercent = round((histogram[expectedColor]!! * 100f) / (width * height))
154     if (actualPercent < minPercent) {
155         throw AssertionError(
156             "Expected color $expectedColor found $actualPercent%, below threshold $minPercent%"
157         )
158     }
159 }
160 
161 /** Checks that [expectedColor] is in the percentage [range] of an [ImageBitmap] color histogram */
ImageBitmapnull162 fun ImageBitmap.assertColorInPercentageRange(
163     expectedColor: Color,
164     range: ClosedFloatingPointRange<Float> = 50.0f..100.0f
165 ) {
166     val histogram = histogram()
167     if (!histogram.containsKey(expectedColor)) {
168         throw AssertionError("Expected color $expectedColor was not found in the bitmap.")
169     }
170 
171     ((histogram[expectedColor]!! * 100f) / (width * height)).let { actualPercent ->
172         if (actualPercent !in range) {
173             throw AssertionError(
174                 "Expected color $expectedColor found " +
175                     "$actualPercent%, not in the percentage range $range"
176             )
177         }
178     }
179 }
180 
181 /** Checks whether [expectedColor] does not exist in current [ImageBitmap] */
assertDoesNotContainColornull182 fun ImageBitmap.assertDoesNotContainColor(expectedColor: Color) {
183     val histogram = histogram()
184     if (histogram.containsKey(expectedColor)) {
185         throw AssertionError("Expected color $expectedColor exists in current bitmap")
186     }
187 }
188 
189 /**
190  * printHistogramToLog - utility for writing an ImageBitmap's color distribution to debug log. The
191  * histogram can be extracted using adb logcat, for example: adb logcat | grep Histogram This can be
192  * useful when debugging a captured image from a compose UI element.
193  */
194 @kotlin.ExperimentalUnsignedTypes
printHistogramToLognull195 fun ImageBitmap.printHistogramToLog(expectedColor: Color): ImageBitmap {
196     val n = width * height
197     Log.d("Histogram", "---------------------------------------------------------------")
198     val expectedColorInHex = expectedColor.toHexString()
199     Log.d("Histogram", "Expecting color $expectedColorInHex")
200     for ((key, value) in histogram()) {
201         val percent = 100 * value / n
202         val colorInHex = key.toHexString()
203         Log.d("Histogram", "$colorInHex : $value ($percent)")
204     }
205 
206     return this
207 }
208 
209 @RequiresApi(Build.VERSION_CODES.O)
210 internal fun ComposeContentTestRule.verifyScreenshot(
211     screenshotRule: AndroidXScreenshotTestRule,
212     methodName: String,
213     testTag: String = TEST_TAG,
214     layoutDirection: LayoutDirection = LayoutDirection.Ltr,
215     content: @Composable () -> Unit
216 ) {
<lambda>null217     setContentWithTheme {
218         CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { content() }
219     }
220 
221     onNodeWithTag(testTag).captureToImage().assertAgainstGolden(screenshotRule, methodName)
222 }
223 
224 /**
225  * Asserts that the layout of this node has height equal to [expectedHeight].
226  *
227  * @throws AssertionError if comparison fails.
228  */
assertHeightIsEqualTonull229 internal fun SemanticsNodeInteraction.assertHeightIsEqualTo(
230     expectedHeight: Dp,
231     tolerance: Dp = Dp(0.5f)
232 ): SemanticsNodeInteraction {
233     return withUnclippedBoundsInRoot {
234         it.height.assertIsEqualTo(expectedHeight, "height", tolerance)
235     }
236 }
237 
SemanticsNodeInteractionnull238 private fun SemanticsNodeInteraction.withUnclippedBoundsInRoot(
239     assertion: (DpRect) -> Unit
240 ): SemanticsNodeInteraction {
241     val node = fetchSemanticsNode("Failed to retrieve bounds of the node.")
242     val bounds =
243         with(node.root!!.density) {
244             node.unclippedBoundsInRoot.let {
245                 DpRect(it.left.toDp(), it.top.toDp(), it.right.toDp(), it.bottom.toDp())
246             }
247         }
248     assertion.invoke(bounds)
249     return this
250 }
251 
252 private val SemanticsNode.unclippedBoundsInRoot: Rect
253     get() {
254         return if (layoutInfo.isPlaced) {
255             Rect(positionInRoot, size.toSize())
256         } else {
<lambda>null257             Dp.Unspecified.value.let { Rect(it, it, it, it) }
258         }
259     }
260 
261 /**
262  * Returns if this value is equal to the [reference], within a given [tolerance]. If the reference
263  * value is [Float.NaN], [Float.POSITIVE_INFINITY] or [Float.NEGATIVE_INFINITY], this only returns
264  * true if this value is exactly the same (tolerance is disregarded).
265  */
Dpnull266 private fun Dp.isWithinTolerance(reference: Dp, tolerance: Dp): Boolean {
267     return when {
268         reference.isUnspecified -> this.isUnspecified
269         reference.value.isInfinite() -> this.value == reference.value
270         else -> abs(this.value - reference.value) <= tolerance.value
271     }
272 }
273 
274 /**
275  * Asserts that this value is equal to the given [expected] value.
276  *
277  * Performs the comparison with the given [tolerance] or the default one if none is provided. It is
278  * recommended to use tolerance when comparing positions and size coming from the framework as there
279  * can be rounding operation performed by individual layouts so the values can be slightly off from
280  * the expected ones.
281  *
282  * @param expected The expected value to which this one should be equal to.
283  * @param subject Used in the error message to identify which item this assertion failed on.
284  * @param tolerance The tolerance within which the values should be treated as equal.
285  * @throws AssertionError if comparison fails.
286  */
Dpnull287 private fun Dp.assertIsEqualTo(expected: Dp, subject: String, tolerance: Dp = Dp(.5f)) {
288     if (!isWithinTolerance(expected, tolerance)) {
289         // Comparison failed, report the error in DPs
290         throw AssertionError("Actual $subject is $this, expected $expected (tolerance: $tolerance)")
291     }
292 }
293 
histogramnull294 private fun ImageBitmap.histogram(): MutableMap<Color, Long> {
295     val pixels = this.toPixelMap()
296     val histogram = mutableMapOf<Color, Long>()
297     for (x in 0 until width) {
298         for (y in 0 until height) {
299             val color = pixels[x, y]
300             histogram[color] = histogram.getOrDefault(color, 0) + 1
301         }
302     }
303     return histogram
304 }
305 
toHexStringnull306 @kotlin.ExperimentalUnsignedTypes private fun Color.toHexString() = this.value.toString(16)
307 
308 /**
309  * writeToDevice - utility for writing an image bitmap to storage on the emulated device. The image
310  * can be extract using adb pull, for example: adb pull
311  * /storage/emulated/0/Android/data/androidx.wear.compose.test/cache/screenshots/mytest.png
312  * /usr/local/username/Desktop/mytest.png
313  */
314 fun ImageBitmap.writeToDevice(testName: String) {
315     this.asAndroidBitmap().writeToDevice(testName)
316 }
317 
318 private val deviceOutputDirectory
319     get() =
320         File(InstrumentationRegistry.getInstrumentation().context.externalCacheDir, "screenshots")
321 
Bitmapnull322 private fun Bitmap.writeToDevice(testName: String): File {
323     return writeToDevice(testName) {
324         compress(Bitmap.CompressFormat.PNG, 0 /*ignored for png*/, it)
325     }
326 }
327 
writeToDevicenull328 private fun writeToDevice(testName: String, writeAction: (FileOutputStream) -> Unit): File {
329     if (!deviceOutputDirectory.exists() && !deviceOutputDirectory.mkdir()) {
330         throw IOException("Could not create folder $deviceOutputDirectory")
331     }
332 
333     val file = File(deviceOutputDirectory, "$testName.png")
334     Log.d("Screenshot", "File path is ${file.absolutePath}")
335     try {
336         FileOutputStream(file).use { writeAction(it) }
337     } catch (e: Exception) {
338         throw IOException(
339             "Could not write file to storage (path: ${file.absolutePath}). " +
340                 " Stacktrace: " +
341                 e.stackTrace
342         )
343     }
344     return file
345 }
346 
347 class StableRef<T>(var value: T)
348