1 /* 2 * Copyright (C) 2015 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 android.widget.cts; 18 19 import static org.junit.Assert.assertEquals; 20 import static org.junit.Assert.assertNotNull; 21 import static org.junit.Assert.assertNull; 22 import static org.mockito.Mockito.any; 23 import static org.mockito.Mockito.mock; 24 import static org.mockito.Mockito.never; 25 import static org.mockito.Mockito.times; 26 import static org.mockito.Mockito.verify; 27 import static org.mockito.Mockito.verifyNoMoreInteractions; 28 29 import android.app.Activity; 30 import android.app.Instrumentation; 31 import android.support.test.InstrumentationRegistry; 32 import android.support.test.annotation.UiThreadTest; 33 import android.support.test.filters.MediumTest; 34 import android.support.test.rule.ActivityTestRule; 35 import android.support.test.runner.AndroidJUnit4; 36 import android.view.Gravity; 37 import android.view.Menu; 38 import android.view.MenuInflater; 39 import android.view.MenuItem; 40 import android.view.SubMenu; 41 import android.view.View; 42 import android.widget.EditText; 43 import android.widget.ImageView; 44 import android.widget.ListView; 45 import android.widget.PopupMenu; 46 47 import com.android.compatibility.common.util.CtsTouchUtils; 48 import com.android.compatibility.common.util.WidgetTestUtils; 49 50 import org.junit.After; 51 import org.junit.Before; 52 import org.junit.Rule; 53 import org.junit.Test; 54 import org.junit.runner.RunWith; 55 56 @MediumTest 57 @RunWith(AndroidJUnit4.class) 58 public class PopupMenuTest { 59 private Instrumentation mInstrumentation; 60 private Activity mActivity; 61 62 private Builder mBuilder; 63 private PopupMenu mPopupMenu; 64 65 @Rule 66 public ActivityTestRule<PopupMenuCtsActivity> mActivityRule = 67 new ActivityTestRule<>(PopupMenuCtsActivity.class); 68 69 70 @UiThreadTest 71 @Before 72 public void setup() { 73 mInstrumentation = InstrumentationRegistry.getInstrumentation(); 74 mActivity = mActivityRule.getActivity(); 75 76 // Disable and remove focusability on the first child of our activity so that 77 // it doesn't bring in the soft keyboard that can mess up with some of the tests 78 // (such as menu dismissal when we emulate a tap outside the menu bounds). 79 final EditText editText = (EditText) mActivity.findViewById(R.id.anchor_upper_left); 80 editText.setEnabled(false); 81 editText.setFocusable(false); 82 } 83 84 @After 85 public void teardown() throws Throwable { 86 if (mPopupMenu != null) { 87 mActivityRule.runOnUiThread(mPopupMenu::dismiss); 88 } 89 } 90 91 private void verifyMenuContent() { 92 final Menu menu = mPopupMenu.getMenu(); 93 assertEquals(6, menu.size()); 94 assertEquals(R.id.action_highlight, menu.getItem(0).getItemId()); 95 assertEquals(R.id.action_edit, menu.getItem(1).getItemId()); 96 assertEquals(R.id.action_delete, menu.getItem(2).getItemId()); 97 assertEquals(R.id.action_ignore, menu.getItem(3).getItemId()); 98 assertEquals(R.id.action_share, menu.getItem(4).getItemId()); 99 assertEquals(R.id.action_print, menu.getItem(5).getItemId()); 100 101 final SubMenu shareSubMenu = menu.getItem(4).getSubMenu(); 102 assertNotNull(shareSubMenu); 103 assertEquals(2, shareSubMenu.size()); 104 assertEquals(R.id.action_share_email, shareSubMenu.getItem(0).getItemId()); 105 assertEquals(R.id.action_share_circles, shareSubMenu.getItem(1).getItemId()); 106 } 107 108 @Test 109 public void testPopulateViaInflater() throws Throwable { 110 mBuilder = new Builder().inflateWithInflater(true); 111 mActivityRule.runOnUiThread(mBuilder::show); 112 mInstrumentation.waitForIdleSync(); 113 114 verifyMenuContent(); 115 } 116 117 @Test 118 public void testDirectPopulate() throws Throwable { 119 mBuilder = new Builder().inflateWithInflater(false); 120 mActivityRule.runOnUiThread(mBuilder::show); 121 mInstrumentation.waitForIdleSync(); 122 123 verifyMenuContent(); 124 } 125 126 @Test 127 public void testAccessGravity() throws Throwable { 128 mBuilder = new Builder(); 129 mActivityRule.runOnUiThread(mBuilder::show); 130 131 assertEquals(Gravity.NO_GRAVITY, mPopupMenu.getGravity()); 132 mPopupMenu.setGravity(Gravity.TOP); 133 assertEquals(Gravity.TOP, mPopupMenu.getGravity()); 134 } 135 136 @Test 137 public void testConstructorWithGravity() throws Throwable { 138 mBuilder = new Builder().withGravity(Gravity.TOP); 139 mActivityRule.runOnUiThread(mBuilder::show); 140 141 assertEquals(Gravity.TOP, mPopupMenu.getGravity()); 142 } 143 144 @Test 145 public void testDismissalViaAPI() throws Throwable { 146 mBuilder = new Builder().withDismissListener(); 147 mActivityRule.runOnUiThread(mBuilder::show); 148 149 mInstrumentation.waitForIdleSync(); 150 verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu); 151 152 mActivityRule.runOnUiThread(mPopupMenu::dismiss); 153 mInstrumentation.waitForIdleSync(); 154 verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu); 155 156 mActivityRule.runOnUiThread(mPopupMenu::dismiss); 157 mInstrumentation.waitForIdleSync(); 158 // Shouldn't have any more interactions with our dismiss listener since the menu was 159 // already dismissed when we called dismiss() 160 verifyNoMoreInteractions(mBuilder.mOnDismissListener); 161 } 162 163 @Test 164 public void testNestedDismissalViaAPI() throws Throwable { 165 // Use empty popup style to remove all transitions from the popup. That way we don't 166 // need to synchronize with the popup window enter transition before proceeding to 167 // "click" a submenu item. 168 mBuilder = new Builder().withDismissListener() 169 .withPopupStyleResource(R.style.PopupWindow_NullTransitions); 170 mActivityRule.runOnUiThread(mBuilder::show); 171 mInstrumentation.waitForIdleSync(); 172 verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu); 173 174 mActivityRule.runOnUiThread( 175 () -> mPopupMenu.getMenu().performIdentifierAction(R.id.action_share, 0)); 176 mInstrumentation.waitForIdleSync(); 177 178 mActivityRule.runOnUiThread( 179 () -> mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu(). 180 performIdentifierAction(R.id.action_share_email, 0)); 181 mInstrumentation.waitForIdleSync(); 182 183 mActivityRule.runOnUiThread(mPopupMenu::dismiss); 184 mInstrumentation.waitForIdleSync(); 185 verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu); 186 187 mActivityRule.runOnUiThread(mPopupMenu::dismiss); 188 mInstrumentation.waitForIdleSync(); 189 // Shouldn't have any more interactions with our dismiss listener since the menu was 190 // already dismissed when we called dismiss() 191 verifyNoMoreInteractions(mBuilder.mOnDismissListener); 192 } 193 194 @Test 195 public void testDismissalViaTouch() throws Throwable { 196 // Use empty popup style to remove all transitions from the popup. That way we don't 197 // need to synchronize with the popup window enter transition before proceeding to 198 // emulate a click outside the popup window bounds. 199 mBuilder = new Builder().withDismissListener() 200 .withPopupMenuContent(R.menu.popup_menu_single) 201 .withPopupStyleResource(R.style.PopupWindow_NullTransitions); 202 mActivityRule.runOnUiThread(mBuilder::show); 203 mInstrumentation.waitForIdleSync(); 204 205 // The call below uses Instrumentation to emulate a tap outside the bounds of the 206 // displayed popup menu. This tap is then treated by the framework to be "split" as 207 // the ACTION_OUTSIDE for the popup itself, as well as DOWN / MOVE / UP for the underlying 208 // view root if the popup is not modal. 209 // It is not correct to emulate these two sequences separately in the test, as it 210 // wouldn't emulate the user-facing interaction for this test. Also, we don't want to use 211 // View.dispatchTouchEvent directly as that would require emulation of two separate 212 // sequences as well. 213 CtsTouchUtils.emulateTapOnView(mInstrumentation, mBuilder.mAnchor, 10, -20); 214 215 // At this point our popup should have notified its dismiss listener 216 verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu); 217 } 218 219 @Test 220 public void testSimpleMenuItemClickViaAPI() throws Throwable { 221 mBuilder = new Builder().withMenuItemClickListener().withDismissListener(); 222 mActivityRule.runOnUiThread(mBuilder::show); 223 224 // Verify that our menu item click listener hasn't been called yet 225 verify(mBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class)); 226 227 mActivityRule.runOnUiThread( 228 () -> mPopupMenu.getMenu().performIdentifierAction(R.id.action_highlight, 0)); 229 230 // Verify that our menu item click listener has been called with the expected menu item 231 verify(mBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick( 232 mPopupMenu.getMenu().findItem(R.id.action_highlight)); 233 234 // Popup menu should be automatically dismissed on selecting an item 235 verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu); 236 verifyNoMoreInteractions(mBuilder.mOnDismissListener); 237 } 238 239 @Test 240 public void testSubMenuClickViaAPI() throws Throwable { 241 // Use empty popup style to remove all transitions from the popup. That way we don't 242 // need to synchronize with the popup window enter transition before proceeding to 243 // "click" a submenu item. 244 mBuilder = new Builder().withDismissListener().withMenuItemClickListener() 245 .withPopupStyleResource(R.style.PopupWindow_NullTransitions); 246 mActivityRule.runOnUiThread(mBuilder::show); 247 mInstrumentation.waitForIdleSync(); 248 249 // Verify that our menu item click listener hasn't been called yet 250 verify(mBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class)); 251 252 mActivityRule.runOnUiThread( 253 () -> mPopupMenu.getMenu().performIdentifierAction(R.id.action_share, 0)); 254 // Verify that our menu item click listener has been called on "share" action 255 // and that the dismiss listener hasn't been called just as a result of opening the submenu. 256 verify(mBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick( 257 mPopupMenu.getMenu().findItem(R.id.action_share)); 258 verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu); 259 260 mActivityRule.runOnUiThread( 261 () -> mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu(). 262 performIdentifierAction(R.id.action_share_email, 0)); 263 264 // Verify that out menu item click listener has been called with the expected menu item 265 verify(mBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick( 266 mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu() 267 .findItem(R.id.action_share_email)); 268 verifyNoMoreInteractions(mBuilder.mOnMenuItemClickListener); 269 270 // Popup menu should be automatically dismissed on selecting an item 271 verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu); 272 verifyNoMoreInteractions(mBuilder.mOnDismissListener); 273 } 274 275 @Test 276 public void testItemViewAttributes() throws Throwable { 277 mBuilder = new Builder().withDismissListener().withAnchorId(R.id.anchor_upper_left); 278 WidgetTestUtils.runOnMainAndLayoutSync(mActivityRule, mBuilder::show, true); 279 280 Menu menu = mPopupMenu.getMenu(); 281 ListView menuItemList = mPopupMenu.getMenuListView(); 282 283 for (int i = 0; i != menu.size(); i++) { 284 MenuItem item = menu.getItem(i); 285 View itemView = null; 286 // On smaller screens, not all menu items will be visible. 287 if (i < menuItemList.getChildCount()) { 288 itemView = menuItemList.getChildAt(i); 289 assertNotNull(itemView); 290 } 291 292 if (i < 2) { 293 assertNotNull(item.getContentDescription()); 294 assertNotNull(item.getTooltipText()); 295 } else { 296 assertNull(item.getContentDescription()); 297 assertNull(item.getTooltipText()); 298 } 299 if (itemView != null) { 300 // Tooltips are not set on list-based menus. 301 assertNull(itemView.getTooltipText()); 302 assertEquals(item.getContentDescription(), itemView.getContentDescription()); 303 } 304 } 305 } 306 307 @Test 308 public void testGroupDividerEnabledAPI() throws Throwable { 309 testGroupDivider(false); 310 testGroupDivider(true); 311 } 312 313 private void testGroupDivider(boolean groupDividerEnabled) throws Throwable { 314 mBuilder = new Builder().withGroupDivider(groupDividerEnabled) 315 .withAnchorId(R.id.anchor_upper_left); 316 WidgetTestUtils.runOnMainAndLayoutSync(mActivityRule, mBuilder::show, true); 317 318 Menu menu = mPopupMenu.getMenu(); 319 ListView menuItemList = mPopupMenu.getMenuListView(); 320 321 for (int i = 0; i < menuItemList.getChildCount(); i++) { 322 final int currGroupId = menu.getItem(i).getGroupId(); 323 final int prevGroupId = 324 i - 1 >= 0 ? menu.getItem(i - 1).getGroupId() : currGroupId; 325 View itemView = menuItemList.getChildAt(i); 326 ImageView groupDivider = itemView.findViewById(com.android.internal.R.id.group_divider); 327 328 assertNotNull(groupDivider); 329 if (!groupDividerEnabled || currGroupId == prevGroupId) { 330 assertEquals(groupDivider.getVisibility(), View.GONE); 331 } else { 332 assertEquals(groupDivider.getVisibility(), View.VISIBLE); 333 } 334 } 335 336 teardown(); 337 } 338 339 /** 340 * Inner helper class to configure an instance of {@link PopupMenu} for the specific test. 341 * The main reason for its existence is that once a popup menu is shown with the show() method, 342 * most of its configuration APIs are no-ops. This means that we can't add logic that is 343 * specific to a certain test once it's shown and we have a reference to a displayed 344 * {@link PopupMenu}. 345 */ 346 public class Builder { 347 private boolean mHasDismissListener; 348 private boolean mHasMenuItemClickListener; 349 private boolean mInflateWithInflater; 350 351 private int mAnchorId = R.id.anchor_middle_left; 352 private int mPopupMenuContent = R.menu.popup_menu; 353 354 private boolean mUseCustomPopupResource; 355 private int mPopupStyleResource = 0; 356 357 private boolean mUseCustomGravity; 358 private int mGravity = Gravity.NO_GRAVITY; 359 360 private PopupMenu.OnMenuItemClickListener mOnMenuItemClickListener; 361 private PopupMenu.OnDismissListener mOnDismissListener; 362 363 private View mAnchor; 364 365 private boolean mGroupDividerEnabled = false; 366 367 public Builder withMenuItemClickListener() { 368 mHasMenuItemClickListener = true; 369 return this; 370 } 371 372 public Builder withDismissListener() { 373 mHasDismissListener = true; 374 return this; 375 } 376 377 public Builder inflateWithInflater(boolean inflateWithInflater) { 378 mInflateWithInflater = inflateWithInflater; 379 return this; 380 } 381 382 public Builder withPopupStyleResource(int popupStyleResource) { 383 mUseCustomPopupResource = true; 384 mPopupStyleResource = popupStyleResource; 385 return this; 386 } 387 388 public Builder withPopupMenuContent(int popupMenuContent) { 389 mPopupMenuContent = popupMenuContent; 390 return this; 391 } 392 393 public Builder withGravity(int gravity) { 394 mUseCustomGravity = true; 395 mGravity = gravity; 396 return this; 397 } 398 399 public Builder withGroupDivider(boolean groupDividerEnabled) { 400 mGroupDividerEnabled = groupDividerEnabled; 401 return this; 402 } 403 404 public Builder withAnchorId(int anchorId) { 405 mAnchorId = anchorId; 406 return this; 407 } 408 409 private void configure() { 410 mAnchor = mActivity.findViewById(mAnchorId); 411 if (!mUseCustomGravity && !mUseCustomPopupResource) { 412 mPopupMenu = new PopupMenu(mActivity, mAnchor); 413 } else if (!mUseCustomPopupResource) { 414 mPopupMenu = new PopupMenu(mActivity, mAnchor, mGravity); 415 } else { 416 mPopupMenu = new PopupMenu(mActivity, mAnchor, Gravity.NO_GRAVITY, 417 0, mPopupStyleResource); 418 } 419 420 if (mInflateWithInflater) { 421 final MenuInflater menuInflater = mPopupMenu.getMenuInflater(); 422 menuInflater.inflate(mPopupMenuContent, mPopupMenu.getMenu()); 423 } else { 424 mPopupMenu.inflate(mPopupMenuContent); 425 } 426 427 if (mHasMenuItemClickListener) { 428 // Register a mock listener to be notified when a menu item in our popup menu has 429 // been clicked. 430 mOnMenuItemClickListener = mock(PopupMenu.OnMenuItemClickListener.class); 431 mPopupMenu.setOnMenuItemClickListener(mOnMenuItemClickListener); 432 } 433 434 if (mHasDismissListener) { 435 // Register a mock listener to be notified when our popup menu is dismissed. 436 mOnDismissListener = mock(PopupMenu.OnDismissListener.class); 437 mPopupMenu.setOnDismissListener(mOnDismissListener); 438 } 439 440 if (mGroupDividerEnabled) { 441 mPopupMenu.getMenu().setGroupDividerEnabled(true); 442 } 443 } 444 445 public void show() { 446 configure(); 447 // Show the popup menu 448 mPopupMenu.show(); 449 } 450 } 451 } 452