| /* |
| * Copyright 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.recyclerview.selection; |
| |
| import static androidx.recyclerview.selection.GridModel.NOT_SET; |
| |
| import static org.junit.Assert.assertEquals; |
| import static org.junit.Assert.assertFalse; |
| import static org.junit.Assert.assertTrue; |
| |
| import android.graphics.Point; |
| import android.graphics.Rect; |
| import android.support.test.filters.SmallTest; |
| import android.support.test.runner.AndroidJUnit4; |
| |
| import androidx.recyclerview.selection.testing.TestAdapter; |
| import androidx.recyclerview.selection.testing.TestItemKeyProvider; |
| import androidx.recyclerview.widget.RecyclerView.OnScrollListener; |
| |
| import org.junit.After; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Set; |
| |
| import javax.annotation.Nullable; |
| |
| @RunWith(AndroidJUnit4.class) |
| @SmallTest |
| public class GridModelTest { |
| |
| private static final int VIEW_PADDING_PX = 5; |
| private static final int CHILD_VIEW_EDGE_PX = 100; |
| private static final int VIEWPORT_HEIGHT = 500; |
| |
| private GridModel<String> mModel; |
| private TestHost mHost; |
| private TestAdapter mAdapter; |
| private Set<String> mLastSelection; |
| private int mViewWidth; |
| |
| // TLDR: Don't call model.{start|resize}Selection; use the local #startSelection and |
| // #resizeSelection methods instead. |
| // |
| // The reason for this is that selection is stateful and involves operations that take the |
| // current UI state (e.g scrolling) into account. This test maintains its own copy of the |
| // selection bounds as control data for verifying selections. Keep this data in sync by calling |
| // #startSelection and |
| // #resizeSelection. |
| private Point mSelectionOrigin; |
| private Point mSelectionPoint; |
| |
| @After |
| public void tearDown() { |
| mModel = null; |
| mHost = null; |
| mLastSelection = null; |
| } |
| |
| @Test |
| public void testSelectionLeftOfItems() { |
| initData(20, 5); |
| startSelection(new Point(0, 10)); |
| resizeSelection(new Point(1, 11)); |
| assertNoSelection(); |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testSelectionRightOfItems() { |
| initData(20, 4); |
| startSelection(new Point(mViewWidth - 1, 10)); |
| resizeSelection(new Point(mViewWidth - 2, 11)); |
| assertNoSelection(); |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testSelectionAboveItems() { |
| initData(20, 4); |
| startSelection(new Point(10, 0)); |
| resizeSelection(new Point(11, 1)); |
| assertNoSelection(); |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testSelectionBelowItems() { |
| initData(5, 4); |
| startSelection(new Point(10, VIEWPORT_HEIGHT - 1)); |
| resizeSelection(new Point(11, VIEWPORT_HEIGHT - 2)); |
| assertNoSelection(); |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testVerticalSelectionBetweenItems() { |
| initData(20, 4); |
| startSelection(new Point(106, 0)); |
| resizeSelection(new Point(107, 200)); |
| assertNoSelection(); |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testHorizontalSelectionBetweenItems() { |
| initData(20, 4); |
| startSelection(new Point(0, 105)); |
| resizeSelection(new Point(200, 106)); |
| assertNoSelection(); |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testGrowingAndShrinkingSelection() { |
| initData(20, 4); |
| startSelection(new Point(0, 0)); |
| |
| resizeSelection(new Point(5, 5)); |
| verifySelection(); |
| |
| resizeSelection(new Point(109, 109)); |
| verifySelection(); |
| |
| resizeSelection(new Point(110, 109)); |
| verifySelection(); |
| |
| resizeSelection(new Point(110, 110)); |
| verifySelection(); |
| |
| resizeSelection(new Point(214, 214)); |
| verifySelection(); |
| |
| resizeSelection(new Point(215, 214)); |
| verifySelection(); |
| |
| resizeSelection(new Point(214, 214)); |
| verifySelection(); |
| |
| resizeSelection(new Point(110, 110)); |
| verifySelection(); |
| |
| resizeSelection(new Point(110, 109)); |
| verifySelection(); |
| |
| resizeSelection(new Point(109, 109)); |
| verifySelection(); |
| |
| resizeSelection(new Point(5, 5)); |
| verifySelection(); |
| |
| resizeSelection(new Point(0, 0)); |
| verifySelection(); |
| |
| assertEquals(NOT_SET, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testSelectionMovingAroundOrigin() { |
| initData(16, 4); |
| |
| startSelection(new Point(210, 210)); |
| resizeSelection(new Point(mViewWidth - 1, 0)); |
| verifySelection(); |
| |
| resizeSelection(new Point(0, 0)); |
| verifySelection(); |
| |
| resizeSelection(new Point(0, 420)); |
| verifySelection(); |
| |
| resizeSelection(new Point(mViewWidth - 1, 420)); |
| verifySelection(); |
| |
| // This is manually figured and will need to be adjusted if the separator position is |
| // changed. |
| assertEquals(7, mModel.getPositionNearestOrigin()); |
| } |
| |
| @Test |
| public void testScrollingBandSelect() { |
| initData(40, 4); |
| |
| startSelection(new Point(0, 0)); |
| resizeSelection(new Point(100, VIEWPORT_HEIGHT - 1)); |
| verifySelection(); |
| |
| scroll(CHILD_VIEW_EDGE_PX); |
| verifySelection(); |
| |
| resizeSelection(new Point(200, VIEWPORT_HEIGHT - 1)); |
| verifySelection(); |
| |
| scroll(CHILD_VIEW_EDGE_PX); |
| verifySelection(); |
| |
| scroll(-2 * CHILD_VIEW_EDGE_PX); |
| verifySelection(); |
| |
| resizeSelection(new Point(100, VIEWPORT_HEIGHT - 1)); |
| verifySelection(); |
| |
| assertEquals(0, mModel.getPositionNearestOrigin()); |
| } |
| |
| private void initData(final int numChildren, int numColumns) { |
| mHost = new TestHost(numChildren, numColumns); |
| mAdapter = new TestAdapter() { |
| @Override |
| public String getSelectionKey(int position) { |
| return Integer.toString(position); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return numChildren; |
| } |
| }; |
| |
| mViewWidth = VIEW_PADDING_PX + numColumns * (VIEW_PADDING_PX + CHILD_VIEW_EDGE_PX); |
| |
| mModel = new GridModel<String>( |
| mHost, |
| new TestItemKeyProvider(ItemKeyProvider.SCOPE_MAPPED, mAdapter), |
| SelectionPredicates.<String>createSelectAnything()); |
| |
| mModel.addOnSelectionChangedListener( |
| new GridModel.SelectionObserver<String>() { |
| @Override |
| public void onSelectionChanged(Set<String> updatedSelection) { |
| mLastSelection = updatedSelection; |
| } |
| }); |
| } |
| |
| /** Returns the current selection area as a Rect. */ |
| private Rect getSelectionArea() { |
| // Construct a rect from the two selection points. |
| Rect selectionArea = new Rect( |
| mSelectionOrigin.x, mSelectionOrigin.y, mSelectionOrigin.x, mSelectionOrigin.y); |
| selectionArea.union(mSelectionPoint.x, mSelectionPoint.y); |
| // Rect intersection tests are exclusive of bounds, while the MSM's selection code is |
| // inclusive. Expand the rect by 1 pixel in all directions to account for this. |
| selectionArea.inset(-1, -1); |
| |
| return selectionArea; |
| } |
| |
| /** Asserts that the selection is currently empty. */ |
| private void assertNoSelection() { |
| assertEquals("Unexpected mItems " + mLastSelection + " in selection " + getSelectionArea(), |
| 0, mLastSelection.size()); |
| } |
| |
| /** Verifies the selection using actual bbox checks. */ |
| private void verifySelection() { |
| Rect selectionArea = getSelectionArea(); |
| for (TestHost.Item item: mHost.mItems) { |
| if (Rect.intersects(selectionArea, item.rect)) { |
| assertTrue("Expected item " + item + " was not in selection " + selectionArea, |
| mLastSelection.contains(item.name)); |
| } else { |
| assertFalse("Unexpected item " + item + " in selection" + selectionArea, |
| mLastSelection.contains(item.name)); |
| } |
| } |
| } |
| |
| private void startSelection(Point p) { |
| mModel.startCapturing(p); |
| mSelectionOrigin = mHost.createAbsolutePoint(p); |
| } |
| |
| private void resizeSelection(Point p) { |
| mModel.resizeSelection(p); |
| mSelectionPoint = mHost.createAbsolutePoint(p); |
| } |
| |
| private void scroll(int dy) { |
| assertTrue(mHost.verticalOffset + VIEWPORT_HEIGHT + dy <= mHost.getTotalHeight()); |
| mHost.verticalOffset += dy; |
| // Correct the cached selection point as well. |
| mSelectionPoint.y += dy; |
| mHost.mScrollListener.onScrolled(null, 0, dy); |
| } |
| |
| private static final class TestHost extends GridModel.GridHost<String> { |
| |
| private final int mNumColumns; |
| private final int mNumRows; |
| private final int mNumChildren; |
| private final int mSeparatorPosition; |
| |
| public int horizontalOffset = 0; |
| public int verticalOffset = 0; |
| private List<Item> mItems = new ArrayList<>(); |
| |
| // Installed by GridModel on construction. |
| private @Nullable OnScrollListener mScrollListener; |
| |
| TestHost(int numChildren, int numColumns) { |
| mNumChildren = numChildren; |
| mNumColumns = numColumns; |
| mSeparatorPosition = mNumColumns + 1; |
| mNumRows = setupGrid(); |
| } |
| |
| private int setupGrid() { |
| // Split the input set into folders and documents. Do this such that there is a |
| // partially-populated row in the middle of the grid, to test corner cases in layout |
| // code. |
| int y = VIEW_PADDING_PX; |
| int i = 0; |
| int numRows = 0; |
| while (i < mNumChildren) { |
| int top = y; |
| int height = CHILD_VIEW_EDGE_PX; |
| int width = CHILD_VIEW_EDGE_PX; |
| for (int j = 0; j < mNumColumns && i < mNumChildren; j++) { |
| int left = VIEW_PADDING_PX + (j * (width + VIEW_PADDING_PX)); |
| mItems.add(new Item( |
| Integer.toString(i), |
| new Rect( |
| left, |
| top, |
| left + width - 1, |
| top + height - 1))); |
| |
| // Create a partially populated row at the separator position. |
| if (++i == mSeparatorPosition) { |
| break; |
| } |
| } |
| y += height + VIEW_PADDING_PX; |
| numRows++; |
| } |
| |
| return numRows; |
| } |
| |
| private int getTotalHeight() { |
| return CHILD_VIEW_EDGE_PX * mNumRows + VIEW_PADDING_PX * (mNumRows + 1); |
| } |
| |
| private int getFirstVisibleRowIndex() { |
| return verticalOffset / (CHILD_VIEW_EDGE_PX + VIEW_PADDING_PX); |
| } |
| |
| private int getLastVisibleRowIndex() { |
| int lastVisibleRowUncapped = |
| (VIEWPORT_HEIGHT + verticalOffset - 1) / (CHILD_VIEW_EDGE_PX + VIEW_PADDING_PX); |
| return Math.min(lastVisibleRowUncapped, mNumRows - 1); |
| } |
| |
| private int getNumItemsInRow(int index) { |
| assertTrue(index >= 0 && index < mNumRows); |
| int mod = mSeparatorPosition % mNumColumns; |
| if (index == (mSeparatorPosition / mNumColumns)) { |
| // The row containing the separator may be incomplete |
| return mod > 0 ? mod : mNumColumns; |
| } |
| // Account for the partial separator row in the final row tally. |
| if (index == mNumRows - 1) { |
| // The last row may be incomplete |
| int finalRowCount = (mNumChildren - mod) % mNumColumns; |
| return finalRowCount > 0 ? finalRowCount : mNumColumns; |
| } |
| |
| return mNumColumns; |
| } |
| |
| @Override |
| public GridModel<String> createGridModel() { |
| throw new UnsupportedOperationException("Not implemented."); |
| } |
| |
| @Override |
| public void addOnScrollListener(OnScrollListener listener) { |
| mScrollListener = listener; |
| } |
| |
| @Override |
| public void removeOnScrollListener(OnScrollListener listener) {} |
| |
| @Override |
| public Point createAbsolutePoint(Point relativePoint) { |
| return new Point( |
| relativePoint.x + horizontalOffset, relativePoint.y + verticalOffset); |
| } |
| |
| @Override |
| public int getVisibleChildCount() { |
| int childCount = 0; |
| for (int i = getFirstVisibleRowIndex(); i <= getLastVisibleRowIndex(); i++) { |
| childCount += getNumItemsInRow(i); |
| } |
| return childCount; |
| } |
| |
| @Override |
| public int getAdapterPositionAt(int index) { |
| // Account for partial rows by actually tallying up the mItems in hidden rows. |
| int hiddenCount = 0; |
| for (int i = 0; i < getFirstVisibleRowIndex(); i++) { |
| hiddenCount += getNumItemsInRow(i); |
| } |
| return index + hiddenCount; |
| } |
| |
| @Override |
| public Rect getAbsoluteRectForChildViewAt(int index) { |
| int adapterPosition = getAdapterPositionAt(index); |
| return mItems.get(adapterPosition).rect; |
| } |
| |
| @Override |
| public int getColumnCount() { |
| return mNumColumns; |
| } |
| |
| @Override |
| public void showBand(Rect rect) { |
| throw new UnsupportedOperationException(); |
| } |
| |
| @Override |
| public void hideBand() { |
| throw new UnsupportedOperationException(); |
| } |
| |
| @Override |
| public boolean hasView(int adapterPosition) { |
| return true; |
| } |
| |
| public static final class Item { |
| public String name; |
| public Rect rect; |
| |
| Item(String n, Rect r) { |
| name = n; |
| rect = r; |
| } |
| |
| @Override |
| public String toString() { |
| return name + ": " + rect; |
| } |
| } |
| } |
| } |