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