1 /*
2 * 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.ui.test
18
19 import androidx.compose.ui.geometry.Rect
20 import androidx.compose.ui.layout.AlignmentLine
21 import androidx.compose.ui.semantics.SemanticsActions
22 import androidx.compose.ui.semantics.SemanticsNode
23 import androidx.compose.ui.semantics.SemanticsProperties
24 import androidx.compose.ui.semantics.getOrNull
25 import androidx.compose.ui.text.AnnotatedString
26 import androidx.compose.ui.text.LinkAnnotation
27 import androidx.compose.ui.text.TextLayoutResult
28 import androidx.compose.ui.unit.Density
29 import androidx.compose.ui.unit.Dp
30 import androidx.compose.ui.unit.DpRect
31 import androidx.compose.ui.unit.height
32 import androidx.compose.ui.unit.isUnspecified
33 import androidx.compose.ui.unit.toSize
34 import androidx.compose.ui.unit.width
35 import kotlin.math.abs
36 import kotlin.math.max
37 import kotlin.math.min
38
39 /**
40 * Asserts that the layout of this node has width equal to [expectedWidth].
41 *
42 * @throws AssertionError if comparison fails.
43 */
SemanticsNodeInteractionnull44 fun SemanticsNodeInteraction.assertWidthIsEqualTo(expectedWidth: Dp): SemanticsNodeInteraction {
45 return withUnclippedBoundsInRoot { it.width.assertIsEqualTo(expectedWidth, "width") }
46 }
47
48 /**
49 * Asserts that the layout of this node has height equal to [expectedHeight].
50 *
51 * @throws AssertionError if comparison fails.
52 */
SemanticsNodeInteractionnull53 fun SemanticsNodeInteraction.assertHeightIsEqualTo(expectedHeight: Dp): SemanticsNodeInteraction {
54 return withUnclippedBoundsInRoot { it.height.assertIsEqualTo(expectedHeight, "height") }
55 }
56
57 /**
58 * Asserts that the touch bounds of this node has width equal to [expectedWidth].
59 *
60 * @throws AssertionError if comparison fails.
61 */
assertTouchWidthIsEqualTonull62 fun SemanticsNodeInteraction.assertTouchWidthIsEqualTo(
63 expectedWidth: Dp
64 ): SemanticsNodeInteraction {
65 return withTouchBoundsInRoot { it.width.assertIsEqualTo(expectedWidth, "width") }
66 }
67
68 /**
69 * Asserts that the touch bounds of this node has height equal to [expectedHeight].
70 *
71 * @throws AssertionError if comparison fails.
72 */
SemanticsNodeInteractionnull73 fun SemanticsNodeInteraction.assertTouchHeightIsEqualTo(
74 expectedHeight: Dp
75 ): SemanticsNodeInteraction {
76 return withTouchBoundsInRoot { it.height.assertIsEqualTo(expectedHeight, "height") }
77 }
78
79 /**
80 * Asserts that the layout of this node has width that is greater than or equal to
81 * [expectedMinWidth].
82 *
83 * @throws AssertionError if comparison fails.
84 */
SemanticsNodeInteractionnull85 fun SemanticsNodeInteraction.assertWidthIsAtLeast(expectedMinWidth: Dp): SemanticsNodeInteraction {
86 return withUnclippedBoundsInRoot { it.width.assertIsAtLeast(expectedMinWidth, "width") }
87 }
88
89 /**
90 * Asserts that the layout of this node has height that is greater than or equal to
91 * [expectedMinHeight].
92 *
93 * @throws AssertionError if comparison fails.
94 */
SemanticsNodeInteractionnull95 fun SemanticsNodeInteraction.assertHeightIsAtLeast(
96 expectedMinHeight: Dp
97 ): SemanticsNodeInteraction {
98 return withUnclippedBoundsInRoot { it.height.assertIsAtLeast(expectedMinHeight, "height") }
99 }
100
101 /**
102 * Asserts that the layout of this node has position in the root composable that is equal to the
103 * given position.
104 *
105 * @param expectedLeft The left (x) position to assert.
106 * @param expectedTop The top (y) position to assert.
107 * @throws AssertionError if comparison fails.
108 */
SemanticsNodeInteractionnull109 fun SemanticsNodeInteraction.assertPositionInRootIsEqualTo(
110 expectedLeft: Dp,
111 expectedTop: Dp
112 ): SemanticsNodeInteraction {
113 return withUnclippedBoundsInRoot {
114 it.left.assertIsEqualTo(expectedLeft, "left")
115 it.top.assertIsEqualTo(expectedTop, "top")
116 }
117 }
118
119 /**
120 * Asserts that the layout of this node has the top position in the root composable that is equal to
121 * the given position.
122 *
123 * @param expectedTop The top (y) position to assert.
124 * @throws AssertionError if comparison fails.
125 */
SemanticsNodeInteractionnull126 fun SemanticsNodeInteraction.assertTopPositionInRootIsEqualTo(
127 expectedTop: Dp
128 ): SemanticsNodeInteraction {
129 return withUnclippedBoundsInRoot { it.top.assertIsEqualTo(expectedTop, "top") }
130 }
131
132 /**
133 * Asserts that the layout of this node has the left position in the root composable that is equal
134 * to the given position.
135 *
136 * @param expectedLeft The left (x) position to assert.
137 * @throws AssertionError if comparison fails.
138 */
SemanticsNodeInteractionnull139 fun SemanticsNodeInteraction.assertLeftPositionInRootIsEqualTo(
140 expectedLeft: Dp
141 ): SemanticsNodeInteraction {
142 return withUnclippedBoundsInRoot { it.left.assertIsEqualTo(expectedLeft, "left") }
143 }
144
145 /**
146 * Returns the bounds of the layout of this node. The bounds are relative to the root composable.
147 */
SemanticsNodeInteractionnull148 fun SemanticsNodeInteraction.getUnclippedBoundsInRoot(): DpRect {
149 lateinit var bounds: DpRect
150 withUnclippedBoundsInRoot { bounds = it }
151 return bounds
152 }
153
154 /**
155 * Returns the bounds of the layout of this node as clipped to the root. The bounds are relative to
156 * the root composable.
157 */
SemanticsNodeInteractionnull158 fun SemanticsNodeInteraction.getBoundsInRoot(): DpRect {
159 val node = fetchSemanticsNode("Failed to retrieve bounds of the node.")
160 return with(node.layoutInfo.density) {
161 node.boundsInRoot.let {
162 DpRect(it.left.toDp(), it.top.toDp(), it.right.toDp(), it.bottom.toDp())
163 }
164 }
165 }
166
167 /**
168 * Returns the position of an [alignment line][AlignmentLine], or [Dp.Unspecified] if the line is
169 * not provided.
170 */
SemanticsNodeInteractionnull171 fun SemanticsNodeInteraction.getAlignmentLinePosition(alignmentLine: AlignmentLine): Dp {
172 return withDensity {
173 val pos = it.getAlignmentLinePosition(alignmentLine)
174 if (pos == AlignmentLine.Unspecified) {
175 Dp.Unspecified
176 } else {
177 pos.toDp()
178 }
179 }
180 }
181
182 /**
183 * Returns the bounds of the first link matching the [predicate], or if that link spans multiple
184 * lines, returns the bounds of the first line of the link.
185 *
186 * A link in a Text composable is defined by a [LinkAnnotation] of the [AnnotatedString].
187 *
188 * The bounds are in the text node's coordinate system.
189 *
190 * You can pass an offset from within the bounds to injection methods to operate them on the link,
191 * for example [TouchInjectionScope.click] or [MouseInjectionScope.moveTo].
192 *
193 * @sample androidx.compose.ui.test.samples.hoverFirstLinkInText
194 * @see performFirstLinkClick
195 */
SemanticsNodeInteractionnull196 fun SemanticsNodeInteraction.getFirstLinkBounds(
197 predicate: (AnnotatedString.Range<LinkAnnotation>) -> Boolean = { true }
<lambda>null198 ): Rect? = withDensity {
199 val errorMessage = "Failed to retrieve bounds of the link."
200 val node = fetchSemanticsNode(errorMessage)
201
202 val texts = node.config.getOrNull(SemanticsProperties.Text)
203 if (texts.isNullOrEmpty())
204 throw AssertionError("$errorMessage\n Reason: No text found on node.")
205
206 if (!(node.config.contains(SemanticsActions.GetTextLayoutResult)))
207 throw AssertionError(
208 "$errorMessage\n Reason: Node doesn't have GetTextLayoutResult action."
209 )
210 // This will contain only one element almost always. The only time when it could have more than
211 // one is if a developer overrides the getTextLayoutResult semantics and adds multiple elements
212 // into the list.
213 val textLayoutResults = mutableListOf<TextLayoutResult>()
214 node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(textLayoutResults)
215
216 val matchedTextLayoutResults = textLayoutResults.filter { texts.contains(it.layoutInput.text) }
217 if (matchedTextLayoutResults.isEmpty()) {
218 throw AssertionError(
219 "$errorMessage\n Reason: No matching TextLayoutResult found for the node's text. This " +
220 "usually indicates that either Text or GetTextLayoutResult semantics have been" +
221 "updated without a corresponding update to the other."
222 )
223 }
224
225 for (textLayoutResult in matchedTextLayoutResults) {
226 val text = textLayoutResult.layoutInput.text
227 val link = text.getLinkAnnotations(0, text.length).firstOrNull(predicate)
228
229 val boundsOfLink =
230 link?.let {
231 val firstCharIndex = it.start
232 val lineForLink = textLayoutResult.getLineForOffset(firstCharIndex)
233 val lastCharIndex = min(textLayoutResult.getLineEnd(lineForLink), it.end) - 1
234
235 val startBB = textLayoutResult.getBoundingBox(firstCharIndex)
236 val endBB = textLayoutResult.getBoundingBox(lastCharIndex)
237
238 Rect(
239 min(startBB.left, endBB.left),
240 startBB.top,
241 max(startBB.right, endBB.right),
242 startBB.bottom
243 )
244 }
245 if (boundsOfLink != null) return@withDensity boundsOfLink
246 }
247 return@withDensity null
248 }
249
withDensitynull250 private fun <R> SemanticsNodeInteraction.withDensity(operation: Density.(SemanticsNode) -> R): R {
251 val node = fetchSemanticsNode("Failed to retrieve density for the node.")
252 val density = node.layoutInfo.density
253 return operation.invoke(density, node)
254 }
255
SemanticsNodeInteractionnull256 private fun SemanticsNodeInteraction.withUnclippedBoundsInRoot(
257 assertion: (DpRect) -> Unit
258 ): SemanticsNodeInteraction {
259 val node = fetchSemanticsNode("Failed to retrieve bounds of the node.")
260 val bounds =
261 with(node.layoutInfo.density) {
262 node.unclippedBoundsInRoot.let {
263 DpRect(it.left.toDp(), it.top.toDp(), it.right.toDp(), it.bottom.toDp())
264 }
265 }
266 assertion.invoke(bounds)
267 return this
268 }
269
SemanticsNodeInteractionnull270 private fun SemanticsNodeInteraction.withTouchBoundsInRoot(
271 assertion: (DpRect) -> Unit
272 ): SemanticsNodeInteraction {
273 val node = fetchSemanticsNode("Failed to retrieve bounds of the node.")
274 val bounds =
275 with(node.layoutInfo.density) {
276 node.touchBoundsInRoot.let {
277 DpRect(it.left.toDp(), it.top.toDp(), it.right.toDp(), it.bottom.toDp())
278 }
279 }
280 assertion.invoke(bounds)
281 return this
282 }
283
284 private val SemanticsNode.unclippedBoundsInRoot: Rect
285 get() {
286 return if (layoutInfo.isPlaced) {
287 Rect(positionInRoot, size.toSize())
288 } else {
<lambda>null289 Dp.Unspecified.value.let { Rect(it, it, it, it) }
290 }
291 }
292
293 /**
294 * Returns if this value is equal to the [reference], within a given [tolerance]. If the reference
295 * value is [Float.NaN], [Float.POSITIVE_INFINITY] or [Float.NEGATIVE_INFINITY], this only returns
296 * true if this value is exactly the same (tolerance is disregarded).
297 */
Dpnull298 private fun Dp.isWithinTolerance(reference: Dp, tolerance: Dp): Boolean {
299 return when {
300 reference.isUnspecified -> this.isUnspecified
301 reference.value.isInfinite() -> this.value == reference.value
302 else -> abs(this.value - reference.value) <= tolerance.value
303 }
304 }
305
306 /**
307 * Asserts that this value is equal to the given [expected] value.
308 *
309 * Performs the comparison with the given [tolerance] or the default one if none is provided. It is
310 * recommended to use tolerance when comparing positions and size coming from the framework as there
311 * can be rounding operation performed by individual layouts so the values can be slightly off from
312 * the expected ones.
313 *
314 * @param expected The expected value to which this one should be equal to.
315 * @param subject Used in the error message to identify which item this assertion failed on.
316 * @param tolerance The tolerance within which the values should be treated as equal.
317 * @throws AssertionError if comparison fails.
318 */
Dpnull319 fun Dp.assertIsEqualTo(expected: Dp, subject: String, tolerance: Dp = Dp(.5f)) {
320 if (!isWithinTolerance(expected, tolerance)) {
321 // Comparison failed, report the error in DPs
322 throw AssertionError("Actual $subject is $this, expected $expected (tolerance: $tolerance)")
323 }
324 }
325
326 /**
327 * Asserts that this value is greater than or equal to the given [expected] value.
328 *
329 * Performs the comparison with the given [tolerance] or the default one if none is provided. It is
330 * recommended to use tolerance when comparing positions and size coming from the framework as there
331 * can be rounding operation performed by individual layouts so the values can be slightly off from
332 * the expected ones.
333 *
334 * @param expected The expected value to which this one should be greater than or equal to.
335 * @param subject Used in the error message to identify which item this assertion failed on.
336 * @param tolerance The tolerance within which the values should be treated as equal.
337 * @throws AssertionError if comparison fails.
338 */
assertIsAtLeastnull339 private fun Dp.assertIsAtLeast(expected: Dp, subject: String, tolerance: Dp = Dp(.5f)) {
340 if (!(isWithinTolerance(expected, tolerance) || (!isUnspecified && this > expected))) {
341 // Comparison failed, report the error in DPs
342 throw AssertionError(
343 "Actual $subject is $this, expected at least $expected (tolerance: $tolerance)"
344 )
345 }
346 }
347