1 /*
<lambda>null2 * Copyright 2020 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 androidx.compose.testutils
18
19 import android.graphics.Bitmap
20 import androidx.annotation.VisibleForTesting
21 import androidx.compose.ui.geometry.Offset
22 import androidx.compose.ui.geometry.Rect
23 import androidx.compose.ui.geometry.Size
24 import androidx.compose.ui.graphics.Canvas
25 import androidx.compose.ui.graphics.Color
26 import androidx.compose.ui.graphics.ImageBitmap
27 import androidx.compose.ui.graphics.Path
28 import androidx.compose.ui.graphics.PixelMap
29 import androidx.compose.ui.graphics.RectangleShape
30 import androidx.compose.ui.graphics.Shape
31 import androidx.compose.ui.graphics.addOutline
32 import androidx.compose.ui.graphics.asAndroidPath
33 import androidx.compose.ui.graphics.asComposePath
34 import androidx.compose.ui.graphics.toPixelMap
35 import androidx.compose.ui.unit.Density
36 import androidx.compose.ui.unit.Dp
37 import androidx.compose.ui.unit.IntOffset
38 import androidx.compose.ui.unit.IntSize
39 import androidx.compose.ui.unit.LayoutDirection
40 import androidx.compose.ui.unit.dp
41 import kotlin.math.abs
42 import kotlin.math.max
43 import kotlin.math.min
44 import kotlin.math.roundToInt
45
46 /**
47 * A helper function to run asserts on [Bitmap].
48 *
49 * @param expectedSize The expected size of the bitmap. Leave null to skip the check.
50 * @param expectedColorProvider Returns the expected color for the provided pixel position. The
51 * returned color is then asserted as the expected one on the given bitmap.
52 * @throws AssertionError if size or colors don't match.
53 */
54 fun ImageBitmap.assertPixels(
55 expectedSize: IntSize? = null,
56 expectedColorProvider: (pos: IntOffset) -> Color?
57 ) {
58 if (expectedSize != null) {
59 if (width != expectedSize.width || height != expectedSize.height) {
60 throw AssertionError(
61 "Bitmap size is wrong! Expected '$expectedSize' but got " + "'$width x $height'"
62 )
63 }
64 }
65
66 val pixel = toPixelMap()
67 for (y in 0 until height) {
68 for (x in 0 until width) {
69 val pxPos = IntOffset(x, y)
70 val expectedClr = expectedColorProvider(pxPos)
71 if (expectedClr != null) {
72 pixel.assertPixelColor(expectedClr, x, y)
73 }
74 }
75 }
76 }
77
78 /** Asserts that the color at a specific pixel in the bitmap at ([x], [y]) is [expected]. */
PixelMapnull79 fun PixelMap.assertPixelColor(
80 expected: Color,
81 x: Int,
82 y: Int,
83 error: (Color) -> String = { color -> "Pixel($x, $y) expected to be $expected, but was $color" }
84 ) {
85 val actual = this[x, y]
<lambda>null86 assert(abs(expected.red - actual.red) < 0.02f) { error(actual) }
<lambda>null87 assert(abs(expected.green - actual.green) < 0.02f) { error(actual) }
<lambda>null88 assert(abs(expected.blue - actual.blue) < 0.02f) { error(actual) }
<lambda>null89 assert(abs(expected.alpha - actual.alpha) < 0.02f) { error(actual) }
90 }
91
92 /**
93 * Asserts that the expected color is present in this bitmap.
94 *
95 * @throws AssertionError if the expected color is not present.
96 */
assertContainsColornull97 fun ImageBitmap.assertContainsColor(expectedColor: Color): ImageBitmap {
98 if (!containsColor(expectedColor)) {
99 throw AssertionError("The given color $expectedColor was not found in the bitmap.")
100 }
101 return this
102 }
103
assertDoesNotContainColornull104 fun ImageBitmap.assertDoesNotContainColor(unexpectedColor: Color): ImageBitmap {
105 if (containsColor(unexpectedColor)) {
106 throw AssertionError("The given color $unexpectedColor was found in the bitmap.")
107 }
108 return this
109 }
110
containsColornull111 private fun ImageBitmap.containsColor(expectedColor: Color): Boolean {
112 val pixels = this.toPixelMap()
113 for (y in 0 until height) {
114 for (x in 0 until width) {
115 val color = pixels[x, y]
116 if (color == expectedColor) {
117 return true
118 }
119 }
120 }
121 return false
122 }
123
124 /**
125 * Asserts that the given [shape] is drawn in the bitmap in the color [shapeColor] on a background
126 * of [backgroundColor].
127 *
128 * The whole background of the bitmap should be filled with the [backgroundColor]. The [shape]'s
129 * size is that of the bitmap, minus the [horizontalPadding] and [verticalPadding] on all sides. The
130 * shape must be aligned in the center.
131 *
132 * To avoid the pixels on the edge of a shape, where anti-aliasing means the pixel is neither the
133 * shape color nor the background color, a gap size can be given with [antiAliasingGap]. Pixels that
134 * are close to the border of the shape are not checked. A larger [antiAliasingGap] means more
135 * pixels are left unchecked, and a gap of 0 pixels means all pixels are tested.
136 *
137 * @param density current [Density] or the screen
138 * @param shape the [Shape] of the foreground
139 * @param shapeColor the color of the foreground shape
140 * @param backgroundColor the color of the background shape
141 * @param antiAliasingGap The size of the border area from the shape outline to leave it untested as
142 * it is likely anti-aliased. Only works for convex shapes. The default is 1 pixel
143 */
ImageBitmapnull144 fun ImageBitmap.assertShape(
145 density: Density,
146 horizontalPadding: Dp,
147 verticalPadding: Dp,
148 backgroundColor: Color,
149 shapeColor: Color,
150 shape: Shape = RectangleShape,
151 antiAliasingGap: Float = with(density) { 1.dp.toPx() }
152 ) =
153 assertShape(
154 density = density,
155 shape = shape,
156 shapeColor = shapeColor,
157 backgroundColor = backgroundColor,
158 backgroundShape = RectangleShape,
159 shapeSize =
160 Size(
<lambda>null161 width - with(density) { horizontalPadding.toPx() * 2 },
<lambda>null162 height - with(density) { verticalPadding.toPx() * 2 }
163 ),
164 antiAliasingGap = antiAliasingGap
165 )
166
167 /**
168 * Asserts that the given [shape] and [backgroundShape] are drawn in the bitmap according to the
169 * given parameters.
170 *
171 * The [shape]'s bounding box should have size [shapeSize] and be centered at [shapeCenter]. The
172 * [backgroundShape]'s bounding box should have size [backgroundSize] and be centered at
173 * [backgroundCenter].
174 *
175 * Pixels that are outside the background area are not checked. Pixels that are outside the [shape]
176 * and inside the [backgroundShape] must be [backgroundColor]. Pixels that are inside the [shape]
177 * must be [shapeColor].
178 *
179 * If [backgroundColor] is `null`, only pixels inside the [shape] are checked.
180 *
181 * Because pixels on the edge of a shape are anti-aliased, pixels that are close the shape's edges
182 * are not checked. Use [antiAliasingGap] to ignore more (or less) pixels around the shape's edges.
183 * A larger [antiAliasingGap] means more pixels are left unchecked, and a gap of 0 pixels means all
184 * pixels are tested.
185 */
186 // TODO (mount, malkov) : to investigate why it flakes when shape is not rect
ImageBitmapnull187 fun ImageBitmap.assertShape(
188 density: Density,
189 shape: Shape,
190 shapeColor: Color,
191 shapeSize: Size = Size(width.toFloat(), height.toFloat()),
192 shapeCenter: Offset = Offset(width / 2f, height / 2f),
193 backgroundShape: Shape = RectangleShape,
194 backgroundColor: Color?,
195 backgroundSize: Size = Size(width.toFloat(), height.toFloat()),
196 backgroundCenter: Offset = Offset(width / 2f, height / 2f),
197 antiAliasingGap: Float = with(density) { 1.dp.toPx() }
198 ) {
199 val pixels = toPixelMap()
200
201 // the bounding box of the foreground shape in the bitmap
202 val shapeBounds =
203 Rect(
204 left = shapeCenter.x - shapeSize.width / 2f,
205 top = shapeCenter.y - shapeSize.height / 2f,
206 right = shapeCenter.x + shapeSize.width / 2f,
207 bottom = shapeCenter.y + shapeSize.height / 2f,
208 )
209 // the bounding box of the background shape in the bitmap
210 val backgroundBounds =
211 Rect(
212 left = backgroundCenter.x - backgroundSize.width / 2f,
213 top = backgroundCenter.y - backgroundSize.height / 2f,
214 right = backgroundCenter.x + backgroundSize.width / 2f,
215 bottom = backgroundCenter.y + backgroundSize.height / 2f,
216 )
217
218 // Convert the shapes into a paths
219 val foregroundPath = shape.asPath(shapeBounds, density)
220 val backgroundPath = backgroundShape.asPath(backgroundBounds, density)
221
inFgBoundsnull222 forEachPixelIn(shapeBounds, backgroundBounds, antiAliasingGap) { x, y, inFgBounds, inBgBounds ->
223 if (inFgBounds && !inBgBounds) {
224 // Only consider the foreground shape
225 if (foregroundPath.contains(x, y, shapeCenter, antiAliasingGap)) {
226 pixels.assertPixelColor(shapeColor, x, y)
227 }
228 } else if (inBgBounds && !inFgBounds) {
229 // Only consider the background shape, if there is one
230 if (
231 backgroundColor != null &&
232 backgroundPath.contains(x, y, backgroundCenter, antiAliasingGap)
233 ) {
234 pixels.assertPixelColor(backgroundColor, x, y)
235 }
236 } else if (inFgBounds /* && inBgBounds */) {
237 // Need to consider both the foreground and background (if there is one)
238 if (foregroundPath.contains(x, y, shapeCenter, antiAliasingGap)) {
239 pixels.assertPixelColor(shapeColor, x, y)
240 } else if (
241 backgroundColor != null &&
242 foregroundPath.notContains(x, y, shapeCenter, antiAliasingGap) &&
243 backgroundPath.contains(x, y, backgroundCenter, antiAliasingGap)
244 ) {
245 pixels.assertPixelColor(backgroundColor, x, y)
246 }
247 }
248 }
249 }
250
ImageBitmapnull251 private fun ImageBitmap.forEachPixelIn(
252 shapeBounds: Rect,
253 backgroundBounds: Rect,
254 margin: Float,
255 block: (x: Int, y: Int, inShapeBounds: Boolean, inBackgroundBounds: Boolean) -> Unit
256 ) {
257 // Iterate over all pixels in the background. Usually that's all we have to do.
258 for (y in rowIndices(backgroundBounds)) {
259 for (x in columnIndices(backgroundBounds)) {
260 block.invoke(x, y, shapeBounds.contains(x, y, margin), true)
261 }
262 }
263 // While checking the background area, we potentially checked a lot of foreground area at the
264 // same time. Try to avoid checking pixels again.
265 val shapeBoundsToCheck = shapeBounds.subtract(backgroundBounds)
266 for (y in rowIndices(shapeBoundsToCheck)) {
267 for (x in columnIndices(shapeBoundsToCheck)) {
268 // Anything contained in the background is already checked
269 if (!backgroundBounds.contains(x, y, margin)) {
270 block.invoke(x, y, true, false)
271 }
272 }
273 }
274 }
275
rowIndicesnull276 private fun ImageBitmap.rowIndices(bounds: Rect): IntRange =
277 bounds.top.coerceAtLeast(0f) until bounds.bottom.coerceAtMost(height.toFloat())
278
279 private fun ImageBitmap.columnIndices(bounds: Rect): IntRange =
280 bounds.left.coerceAtLeast(0f) until bounds.right.coerceAtMost(width.toFloat())
281
282 private infix fun Float.until(until: Float): IntRange {
283 val from = this.roundToInt()
284 val to = until.roundToInt()
285 if (from <= Int.MIN_VALUE) return IntRange.EMPTY
286 return from until to
287 }
288
Shapenull289 private fun Shape.asPath(bounds: Rect, density: Density): Path {
290 return android.graphics.Path().asComposePath().apply {
291 addOutline(createOutline(bounds.size, LayoutDirection.Ltr, density))
292 // Translate only modifies segments already added, so call it after addOutline
293 translate(bounds.topLeft)
294 }
295 }
296
Pathnull297 private fun Path.contains(x: Int, y: Int, center: Offset, margin: Float): Boolean =
298 contains(pointFartherFromAnchor(x, y, center, margin))
299
300 private fun Path.notContains(x: Int, y: Int, center: Offset, margin: Float): Boolean =
301 !contains(pointCloserToAnchor(x, y, center, margin))
302
303 /**
304 * Tests to see if the given point is within the path. (That is, whether the point would be in the
305 * visible portion of the path if the path was used with [Canvas.clipPath].)
306 *
307 * The `point` argument is interpreted as an offset from the origin.
308 *
309 * Returns true if the point is in the path, and false otherwise.
310 */
311 @VisibleForTesting
312 internal fun Path.contains(offset: Offset): Boolean {
313 val path = android.graphics.Path()
314 path.addRect(
315 /* left = */ offset.x - 0.01f,
316 /* top = */ offset.y - 0.01f,
317 /* right = */ offset.x + 0.01f,
318 /* bottom = */ offset.y + 0.01f,
319 /* dir = */ android.graphics.Path.Direction.CW
320 )
321 if (path.op(asAndroidPath(), android.graphics.Path.Op.INTERSECT)) {
322 return !path.isEmpty
323 }
324 return false
325 }
326
pointCloserToAnchornull327 private fun pointCloserToAnchor(x: Int, y: Int, anchor: Offset, delta: Float): Offset {
328 val xx =
329 when {
330 x > anchor.x -> max(x - delta, anchor.x)
331 x < anchor.x -> min(x + delta, anchor.x)
332 else -> x.toFloat()
333 }
334 val yy =
335 when {
336 y > anchor.y -> max(y - delta, anchor.y)
337 y < anchor.y -> min(y + delta, anchor.y)
338 else -> y.toFloat()
339 }
340 return Offset(xx, yy)
341 }
342
pointFartherFromAnchornull343 private fun pointFartherFromAnchor(x: Int, y: Int, anchor: Offset, delta: Float): Offset {
344 val xx =
345 when {
346 x > anchor.x -> x + delta
347 x < anchor.x -> x - delta
348 else -> x.toFloat()
349 }
350 val yy =
351 when {
352 y > anchor.y -> y + delta
353 y < anchor.y -> y - delta
354 else -> y.toFloat()
355 }
356 return Offset(xx, yy)
357 }
358
Rectnull359 private fun Rect.contains(x: Int, y: Int, margin: Float): Boolean =
360 left <= x + margin && top <= y + margin && right >= x - margin && bottom >= y - margin
361
362 private fun Rect.subtract(other: Rect): Rect {
363 // Subtraction can only happen if the other rect is overlapping entirely in a dimension
364 if (other.left <= this.left && other.right >= this.right) {
365 // Other rect potentially overlaps over entire width
366 return if (other.top <= this.top && other.bottom >= this.bottom) {
367 // Subtract everything
368 Rect.Zero
369 } else if (other.top <= this.top && other.bottom > this.top) {
370 // Subtract from the top
371 Rect(this.left, other.bottom, this.right, this.bottom)
372 } else if (other.top < this.bottom && other.bottom >= this.bottom) {
373 // Subtract from the bottom
374 Rect(this.left, this.top, this.right, other.top)
375 } else {
376 // Subtract nothing
377 this
378 }
379 } else if (other.top <= this.top && other.bottom >= this.bottom) {
380 // Other rect potentially overlaps over entire height
381 return if (other.left <= this.left && other.right > this.left) {
382 // Subtract from the left
383 Rect(other.right, this.top, this.right, this.bottom)
384 } else if (other.left < this.right && other.right >= this.right) {
385 // Subtract from the right
386 Rect(this.left, this.top, other.left, this.bottom)
387 } else {
388 // Subtract nothing
389 this
390 }
391 } else {
392 // Subtract nothing
393 return this
394 }
395 }
396