1 /*
2 * Copyright (C) 2025 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * ```
8 * http://www.apache.org/licenses/LICENSE-2.0
9 * ```
10 *
11 * Unless required by applicable law or agreed to in writing, software distributed under the License
12 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
13 * or implied. See the License for the specific language governing permissions and limitations under
14 * the License.
15 */
16 package com.android.healthconnect.controller.tests.utils
17
18 import android.view.View
19 import android.view.ViewGroup
20 import androidx.recyclerview.widget.RecyclerView
21 import androidx.test.espresso.Espresso.onView
22 import androidx.test.espresso.assertion.ViewAssertions.matches
23 import androidx.test.espresso.matcher.BoundedMatcher
24 import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
25 import androidx.test.espresso.matcher.ViewMatchers.hasSibling
26 import androidx.test.espresso.matcher.ViewMatchers.isChecked
27 import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA
28 import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
29 import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
30 import androidx.test.espresso.matcher.ViewMatchers.withId
31 import androidx.test.espresso.matcher.ViewMatchers.withTagValue
32 import androidx.test.espresso.matcher.ViewMatchers.withText
33 import org.hamcrest.Description
34 import org.hamcrest.Matcher
35 import org.hamcrest.Matchers.allOf
36 import org.hamcrest.Matchers.`is`
37 import org.hamcrest.Matchers.not
38 import org.hamcrest.TypeSafeMatcher
39
40 /**
41 * A custom matcher used when there are more than one view with the same resourceId/text/contentDesc
42 * etc.
43 *
44 * @param matcher a view matcher for UI element which may have potentially more than one matched.
45 * Typical example is the element that is repeated in ListView or RecyclerView
46 * @param index the index to select matcher if there are more than one matcher. it's started at
47 * zero.
48 * @return the view matcher selected from given matcher and index.
49 */
withIndexnull50 fun withIndex(matcher: Matcher<View?>, index: Int): Matcher<View?> {
51 return object : TypeSafeMatcher<View?>() {
52 var currentIndex = 0
53
54 override fun describeTo(description: Description) {
55 description.appendText("with index: ")
56 description.appendValue(index)
57 matcher.describeTo(description)
58 }
59
60 override fun matchesSafely(view: View?): Boolean {
61 return matcher.matches(view) && currentIndex++ == index
62 }
63 }
64 }
65
66 /**
67 * A custom matcher to find a CheckBox based on its sibling TextView's text.
68 *
69 * It is specifically useful for matching a CheckBox in a `SelectorWithWidgetPreference` layout,
70 * where the CheckBox and the associated TextView are side by side within separate LinearLayouts.
71 *
72 * @param keyText The text of the associated TextView to locate the CheckBox.
73 * @return A Matcher for the CheckBox that matches the given text of the sibling TextView.
74 */
checkBoxOfnull75 fun checkBoxOf(keyText: String): Matcher<View> {
76 return allOf(
77 withId(android.R.id.checkbox),
78 isDescendantOfA(
79 allOf(withId(android.R.id.widget_frame), hasSibling(hasDescendant(withText(keyText))))
80 ),
81 )
82 }
83
84 /**
85 * Matches a view that is an indirect sibling of a given target view. An indirect sibling means the
86 * view is a descendant of a view that has a sibling which contains the target view somewhere in its
87 * hierarchy.
88 *
89 * Example: If `View A` and `View B` are inside `Parent 1`, and `Parent 1` has a sibling `Parent 2`
90 * that contains `View C`, then `View A` has an indirect sibling relationship with `View C`.
91 *
92 * @param targetMatcher The matcher for the target view that should be an indirect sibling.
93 * @return A matcher that verifies the indirect sibling relationship.
94 */
hasIndirectSiblingnull95 fun hasIndirectSibling(targetMatcher: Matcher<View>): Matcher<View> {
96 return isDescendantOfA(hasSibling(hasDescendant(targetMatcher)))
97 }
98
99 /** A custom matcher to find a [Preference] with the given title and summary. */
withTitleAndSummarynull100 fun withTitleAndSummary(titleText: String, summaryText: String): Matcher<View> {
101 return allOf(
102 withId(android.R.id.title),
103 withText(titleText),
104 hasSibling(hasDescendant(withText(summaryText))),
105 )
106 }
107
108 /** A custom matcher to find a [Preference] with the given title and no visible summary. */
withTitleNoSummarynull109 fun withTitleNoSummary(titleText: String): Matcher<View> {
110 return allOf(
111 withId(android.R.id.title),
112 withText(titleText),
113 hasSibling(hasDescendant(allOf(withId(android.R.id.summary), not(isDisplayed())))),
114 )
115 }
116
atPositionnull117 fun atPosition(position: Int, itemMatcher: Matcher<View?>): Matcher<View?> {
118 return object : BoundedMatcher<View?, RecyclerView>(RecyclerView::class.java) {
119 override fun describeTo(description: Description) {
120 description.appendText("has item at position $position: ")
121 itemMatcher.describeTo(description)
122 }
123
124 override fun matchesSafely(view: RecyclerView): Boolean {
125 val viewHolder: RecyclerView.ViewHolder =
126 view.findViewHolderForAdapterPosition(position)
127 ?: // has no item on such position
128 return false
129 return itemMatcher.matches(viewHolder.itemView)
130 }
131 }
132 }
133
isAbovenull134 fun isAbove(otherViewMatcher: Matcher<View>): TypeSafeMatcher<View> {
135 return object : TypeSafeMatcher<View>() {
136 private var otherView: View? = null
137
138 override fun describeTo(description: Description) {
139 description.appendText("is above view: ")
140 otherViewMatcher.describeTo(description)
141 }
142
143 override fun matchesSafely(view: View): Boolean {
144 if (otherView == null) {
145 otherView = view.rootView.findViewByMatcher(otherViewMatcher)
146 if (otherView == null) return false // Other view not found
147 }
148
149 val location1 = IntArray(2)
150 val location2 = IntArray(2)
151 view.getLocationOnScreen(location1)
152 otherView!!.getLocationOnScreen(location2) // Safe call as otherView might be null
153
154 return location1[1] < location2[1] // Compare y-coordinates
155 }
156 }
157 }
158
159 // Extension function to find a view by matcher
findViewByMatchernull160 private fun View.findViewByMatcher(matcher: Matcher<View>): View? {
161 if (matcher.matches(this)) return this // Check if this view itself matches
162 if (this is ViewGroup) {
163 for (i in 0 until childCount) {
164 val child = getChildAt(i)
165 val foundView = child.findViewByMatcher(matcher)
166 if (foundView != null) return foundView
167 }
168 }
169 return null // No match found
170 }
171
172 // Checkbox assertions
assertCheckboxNotCheckednull173 fun assertCheckboxNotChecked(
174 recyclerViewId: Int,
175 title: String,
176 position: Int,
177 tag: String = "checkbox",
178 ) {
179 onView(withId(recyclerViewId))
180 .check(
181 matches(
182 atPosition(
183 position,
184 allOf(
185 hasDescendant(withText(title)),
186 hasDescendant(withTagValue(`is`(tag))),
187 hasDescendant(isNotChecked()),
188 ),
189 )
190 )
191 )
192 }
193
assertCheckboxNotShownnull194 fun assertCheckboxNotShown(
195 recyclerViewId: Int,
196 title: String,
197 position: Int,
198 tag: String = "checkbox",
199 ) {
200 onView(withId(recyclerViewId))
201 .check(
202 matches(
203 atPosition(
204 position,
205 allOf(
206 hasDescendant(withText(title)),
207 not(hasDescendant(withTagValue(`is`(tag)))),
208 ),
209 )
210 )
211 )
212 }
213
assertCheckboxCheckednull214 fun assertCheckboxChecked(
215 recyclerViewId: Int,
216 title: String,
217 position: Int,
218 tag: String = "checkbox",
219 ) {
220 onView(withId(recyclerViewId))
221 .check(
222 matches(
223 atPosition(
224 position,
225 allOf(
226 hasDescendant(withText(title)),
227 hasDescendant(withTagValue(`is`(tag))),
228 hasDescendant(isChecked()),
229 ),
230 )
231 )
232 )
233 }
234