From d7eedf73912a1042803dbbe530d4c72976a9bc17 Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Sat, 15 Jun 2024 12:47:58 +0200 Subject: [PATCH] Add UI tests for DpadDragHelper --- .../test/RecyclerViewFragment.kt | 6 +- .../dpadrecyclerview/test/TestAdapter.kt | 23 ++ .../test/tests/AbstractTestAdapter.kt | 5 +- .../test/tests/drag/DragHelperGridTest.kt | 236 +++++++++++++ .../test/tests/drag/DragHelperLinearTest.kt | 313 ++++++++++++++++++ .../test/tests/drag/DragHelperTestFragment.kt | 40 +++ 6 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt index c019258b..709d2f31 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt @@ -26,7 +26,11 @@ class RecyclerViewFragment : Fragment(R.layout.dpadrecyclerview_test_container) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - view.findViewById(R.id.recyclerView).requestFocus() + getRecyclerView().requestFocus() + } + + fun getRecyclerView(): DpadRecyclerView { + return requireView().findViewById(R.id.recyclerView) } } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt index 9d58900f..71dbab6a 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt @@ -76,4 +76,27 @@ class TestAdapter( } + fun assertContents(predicate: (index: Int) -> Int) { + for (i in 0 until itemCount) { + val item = getItem(i) + val expectedItem = predicate(i) + if (expectedItem != item) { + throw AssertionError("Expected item $expectedItem at position $i but got $item instead. Adapter contents: ${getAdapterContentString()}") + } + } + } + + private fun getAdapterContentString(): String { + val builder = StringBuilder() + builder.append("[") + for (i in 0 until itemCount) { + builder.append(getItem(i)) + if (i < itemCount - 1) { + builder.append(", ") + } + } + builder.append("]") + return builder.toString() + } + } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt index 3350ca8a..263dc3be 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt @@ -21,13 +21,14 @@ import android.os.Looper import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil.DiffResult import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadDragHelper import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration import java.util.Collections import java.util.concurrent.Executors abstract class AbstractTestAdapter( adapterConfiguration: TestAdapterConfiguration -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter(), DpadDragHelper.DragAdapter { companion object { private val EMPTY_LIST = ArrayList(0) @@ -43,6 +44,8 @@ abstract class AbstractTestAdapter( private var currentVersion = 0 private var id = items.size + override fun getMutableItems(): MutableList = items + fun submitList(newList: MutableList, commitCallback: Runnable? = null) { val version = ++currentVersion if (items === newList) { diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt new file mode 100644 index 00000000..da9de17a --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2024 Rúben Sousa + * + * 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 com.rubensousa.dpadrecyclerview.test.tests.drag + +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.platform.app.InstrumentationRegistry +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.RecyclerViewFragment +import com.rubensousa.dpadrecyclerview.test.TestAdapter +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.R +import org.junit.Before +import org.junit.Test + +class DragHelperGridTest { + + + private lateinit var fragmentScenario: FragmentScenario + private lateinit var dragHelper: DpadDragHelper + private lateinit var testAdapter: TestAdapter + private val numberOfItems = 50 + private val spanCount = 5 + private val dragStarted = mutableListOf() + private var dragStopCount = 0 + + @Before + fun setup() { + fragmentScenario = launchFragment() + onRecyclerView("Setup RecyclerView ") { recyclerView -> + testAdapter = TestAdapter( + adapterConfiguration = TestAdapterConfiguration( + itemLayoutId = R.layout.dpadrecyclerview_test_item_grid, + numberOfItems = numberOfItems + ), + onViewHolderSelected = { + + }, + onViewHolderDeselected = { + + } + ) + recyclerView.apply { + adapter = testAdapter + setSpanCount(spanCount) + addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = resources.getDimensionPixelSize( + com.rubensousa.dpadrecyclerview.test.R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + } + dragHelper = DpadDragHelper( + adapter = testAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + dragStarted.add(viewHolder) + } + + override fun onDragStopped() { + dragStopCount++ + } + } + ) + dragHelper.attachToRecyclerView(recyclerView) + } + } + + @Test + fun testDragStartRowToEndRow() { + // given + val endRowPosition = spanCount - 1 + startDragging(position = 0) + + // when + KeyEvents.pressRight(times = spanCount) + + // then + assertFocusAndSelection(position = endRowPosition) + testAdapter.assertContents { index -> + when { + index < spanCount - 1 -> index + 1 + index == spanCount - 1 -> 0 + else -> index + } + } + } + + @Test + fun testDragEndRowToStartRow() { + // given + val endRowPosition = spanCount - 1 + startDragging(position = endRowPosition) + + // when + KeyEvents.pressLeft(times = spanCount) + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when { + index == 0 -> endRowPosition + index < spanCount -> index - 1 + else -> index + } + } + } + + @Test + fun testDragColumnTopToColumnBottom() { + // given + val topColumnPosition = spanCount - 1 + val bottomColumnPosition = topColumnPosition + spanCount + startDragging(position = topColumnPosition) + + // when + KeyEvents.pressDown(times = 1) + + // then + assertFocusAndSelection(position = bottomColumnPosition) + testAdapter.assertContents { index -> + when { + index < topColumnPosition -> index + index < bottomColumnPosition -> index + 1 + index == bottomColumnPosition -> topColumnPosition + else -> index + } + } + } + + @Test + fun testDragColumnBottomToColumnTop() { + // given + val topColumnPosition = spanCount - 1 + val bottomColumnPosition = topColumnPosition + spanCount + startDragging(position = bottomColumnPosition) + + // when + KeyEvents.pressUp(times = 1) + + // then + assertFocusAndSelection(position = bottomColumnPosition) + testAdapter.assertContents { index -> + when { + index < topColumnPosition -> index + index == topColumnPosition -> bottomColumnPosition + index < bottomColumnPosition -> index - 1 + index == bottomColumnPosition -> topColumnPosition + else -> index + } + } + } + + @Test + fun testDragStartToEnd() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressDown(times = numberOfItems / spanCount) + KeyEvents.pressRight(times = spanCount) + + // then + assertFocusAndSelection(position = numberOfItems - 1) + testAdapter.assertContents { index -> + when { + index < numberOfItems - 1 -> index + 1 + else -> 0 + } + } + } + + @Test + fun testDragEndToStart() { + // given + startDragging(position = numberOfItems - 1) + + // when + KeyEvents.pressUp(times = numberOfItems / spanCount) + KeyEvents.pressLeft(times = spanCount) + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when { + index == 0 -> numberOfItems - 1 + else -> index - 1 + } + } + } + + private fun startDragging(position: Int) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.startDrag(position) + } + } + + private fun stopDragging() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.stopDrag() + } + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = R.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt new file mode 100644 index 00000000..1ef42569 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2024 Rúben Sousa + * + * 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 com.rubensousa.dpadrecyclerview.test.tests.drag + +import android.view.KeyEvent +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.spacing.DpadLinearSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.RecyclerViewFragment +import com.rubensousa.dpadrecyclerview.test.TestAdapter +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.R +import org.junit.Before +import org.junit.Test + +class DragHelperLinearTest { + + private lateinit var fragmentScenario: FragmentScenario + private lateinit var dragHelper: DpadDragHelper + private lateinit var testAdapter: TestAdapter + private val numberOfItems = 10 + private val dragStarted = mutableListOf() + private var dragStopCount = 0 + + @Before + fun setup() { + fragmentScenario = launchFragment() + onRecyclerView("Setup RecyclerView ") { recyclerView -> + testAdapter = TestAdapter( + adapterConfiguration = TestAdapterConfiguration( + itemLayoutId = R.layout.dpadrecyclerview_test_item_horizontal, + numberOfItems = numberOfItems + ), + onViewHolderSelected = { + + }, + onViewHolderDeselected = { + + } + ) + recyclerView.apply { + adapter = testAdapter + setOrientation(RecyclerView.HORIZONTAL) + addItemDecoration( + DpadLinearSpacingDecoration.create( + itemSpacing = resources.getDimensionPixelSize( + com.rubensousa.dpadrecyclerview.test.R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + } + dragHelper = DpadDragHelper( + adapter = testAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + dragStarted.add(viewHolder) + } + + override fun onDragStopped() { + dragStopCount++ + } + } + ) + dragHelper.attachToRecyclerView(recyclerView) + } + } + + @Test + fun testDetachRecyclerViewStopsDragging() { + // given + startDragging(position = 0) + + // when + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.detachFromRecyclerView() + } + + // then + assertThat(dragStopCount).isEqualTo(1) + } + + @Test + fun testDragStartCallback() { + // given + val position = 0 + + // when + startDragging(position) + + // then + val viewHolder = dragStarted.first() + assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(position) + } + + @Test + fun testDragStartsInAnyPosition() { + // given + val position = numberOfItems - 1 + + // when + startDragging(position) + + // then + assertFocusAndSelection(position) + val viewHolder = dragStarted.first() + assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(position) + } + + @Test + fun testDragIsCanceledOnCertainKeyEvents() { + // given + val cancelKeyCodes = setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_BACK, + ) + + // when + cancelKeyCodes.forEach { keyCode -> + startDragging(position = 0) + KeyEvents.pressKey(keyCode) + Espresso.onIdle() + } + + // then + assertThat(dragStopCount).isEqualTo(cancelKeyCodes.size) + } + + @Test + fun testDragStopRestoresFocusScrolling() { + // given + startDragging(position = 0) + + // when + stopDragging() + KeyEvents.pressRight() + waitForIdleScrollState() + + // then + assertFocusAndSelection(position = 1) + } + + @Test + fun testIsDraggingIsTrueAfterStartDragging() { + // when + startDragging(position = 0) + + // then + assertThat(dragHelper.isDragging).isTrue() + } + + @Test + fun testIsDraggingIsFalseAfterStop() { + // given + startDragging(position = 0) + + // when + stopDragging() + + // then + assertThat(dragHelper.isDragging).isFalse() + } + + @Test + fun testDragMoveForward() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressRight() + + // then + assertFocusAndSelection(position = 1) + testAdapter.assertContents { index -> + when (index) { + 0 -> 1 + 1 -> 0 + else -> index + } + } + } + + @Test + fun testDragMoveBackward() { + // given + startDragging(position = 1) + + // when + KeyEvents.pressLeft() + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when (index) { + 0 -> 1 + 1 -> 0 + else -> index + } + } + } + + @Test + fun testDragStartEdgeDoesNothing() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressLeft() + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> index } + } + + @Test + fun testDragEndEdgeDoesNothing() { + // given + startDragging(position = numberOfItems - 1) + + // when + KeyEvents.pressRight() + + // then + assertFocusAndSelection(numberOfItems - 1) + testAdapter.assertContents { index -> index } + } + + @Test + fun testDragFromStartToEnd() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressRight(times = numberOfItems) + waitForIdleScrollState() + + // then + assertFocusAndSelection(position = numberOfItems - 1) + testAdapter.assertContents { index -> + when (index) { + numberOfItems - 1 -> 0 + else -> index + 1 + } + } + } + + @Test + fun testDragFromEndToStart() { + // given + startDragging(position = numberOfItems - 1) + + // when + KeyEvents.pressLeft(times = numberOfItems) + waitForIdleScrollState() + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when (index) { + 0 -> numberOfItems - 1 + else -> index - 1 + } + } + } + + private fun startDragging(position: Int) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.startDrag(position) + } + } + + private fun stopDragging() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.stopDrag() + } + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = R.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt new file mode 100644 index 00000000..e96430d2 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Rúben Sousa + * + * 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 com.rubensousa.dpadrecyclerview.test.tests.drag + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.rubensousa.dpadrecyclerview.DpadRecyclerView +import com.rubensousa.dpadrecyclerview.testing.R + +class DragHelperTestFragment : Fragment(R.layout.dpadrecyclerview_test_container) { + + var recyclerView: DpadRecyclerView? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + recyclerView = view.findViewById(R.id.recyclerView) + recyclerView?.requestFocus() + } + + override fun onDestroyView() { + super.onDestroyView() + recyclerView = null + } + +}