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