From 61fddb8ca5e191d86eb29e7dff2b5815baabedab Mon Sep 17 00:00:00 2001 From: Ruben Sousa Date: Tue, 4 Jun 2024 19:19:05 +0200 Subject: [PATCH] Add UI tests for grid spans with headers not focusable --- .../test/RecyclerViewFragment.kt | 32 ++ .../helpers/RecyclerViewTestExtensions.kt | 4 + .../test/helpers/TestAdapter.kt | 134 ++++++++ .../scrolling/GridSpanHeaderScrollTest.kt | 307 ++++++++++++++++++ .../layout/dpadrecyclerview_grid_header.xml | 22 ++ .../res/layout/dpadrecyclerview_item_grid.xml | 22 ++ .../src/androidTest/res/values/dimens.xml | 19 ++ .../ui/widgets/common/MutableListAdapter.kt | 5 +- 8 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt create mode 100644 dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt create mode 100644 dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml create mode 100644 dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml create mode 100644 dpadrecyclerview/src/androidTest/res/values/dimens.xml diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt new file mode 100644 index 00000000..c019258b --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt @@ -0,0 +1,32 @@ +/* + * 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 + +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 RecyclerViewFragment : Fragment(R.layout.dpadrecyclerview_test_container) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.recyclerView).requestFocus() + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt index 53ebee4b..2cb065c7 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/RecyclerViewTestExtensions.kt @@ -207,6 +207,10 @@ fun waitForCondition( .perform(DpadViewActions.waitForCondition(description, condition)) } +fun waitForLayout() { + waitForCondition("Waiting for layout") { recyclerView -> !recyclerView.isLayoutRequested } +} + fun onRecyclerView( description: String, id: Int = R.id.recyclerView, diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt new file mode 100644 index 00000000..0e38c17b --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/helpers/TestAdapter.kt @@ -0,0 +1,134 @@ +/* + * 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.helpers + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import java.util.Collections +import java.util.concurrent.Executors + +abstract class TestAdapter: RecyclerView.Adapter() { + + companion object { + private val BACKGROUND_EXECUTOR = Executors.newFixedThreadPool(4) + private val MAIN_THREAD_HANDLER = Handler(Looper.getMainLooper()) + } + + private var items: MutableList = Collections.emptyList() + private var currentVersion = 0 + + @SuppressLint("NotifyDataSetChanged") + fun replaceList(items: MutableList) { + this.items = items + currentVersion++ + notifyDataSetChanged() + } + + fun submitList(newList: MutableList, commitCallback: Runnable? = null) { + val version = ++currentVersion + if (items === newList) { + commitCallback?.run() + return + } + + if (newList.isEmpty()) { + val removed = items.size + items = Collections.emptyList() + notifyItemRangeRemoved(0, removed) + return + } + + if (items.isEmpty()) { + items = newList + notifyItemRangeInserted(0, newList.size) + return + } + + val oldList = items + BACKGROUND_EXECUTOR.execute { + val diffResult = calculateDiff(oldList, newList) + MAIN_THREAD_HANDLER.post { + if (version == currentVersion) { + latchList(newList, diffResult, commitCallback) + } + } + } + } + + private fun calculateDiff( + oldList: List, + newList: List + ): DiffUtil.DiffResult { + return DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem == newItem + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem == newItem + } + }) + } + + private fun latchList( + newList: MutableList, + result: DiffUtil.DiffResult, + commitCallback: Runnable? + ) { + items = newList + result.dispatchUpdatesTo(this) + commitCallback?.run() + } + + fun removeAt(index: Int) { + if (index >= 0 && index < items.size) { + currentVersion++ + items.removeAt(index) + notifyItemRemoved(index) + } + + } + + fun move(from: Int, to: Int) { + currentVersion++ + Collections.swap(items, from, to) + notifyItemMoved(from, to) + } + + fun addAt(index: Int, item: Int) { + currentVersion++ + items.add(index, item) + notifyItemInserted(index) + } + + override fun getItemCount(): Int = items.size + + fun getItem(position: Int) = items[position] +} \ No newline at end of file diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt new file mode 100644 index 00000000..4bebe26a --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/scrolling/GridSpanHeaderScrollTest.kt @@ -0,0 +1,307 @@ +/* + * 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.scrolling + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadSpanSizeLookup +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.R +import com.rubensousa.dpadrecyclerview.test.RecyclerViewFragment +import com.rubensousa.dpadrecyclerview.test.helpers.TestAdapter +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusPosition +import com.rubensousa.dpadrecyclerview.test.helpers.assertSelectedPosition +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.test.helpers.waitForLayout +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.math.abs +import com.rubensousa.dpadrecyclerview.testing.R as RTesting + +private val headerViewType = 0 +private val normalViewType = 1 + +class GridSpanHeaderScrollTest { + + @get:Rule + val idleTimeoutRule = DisableIdleTimeoutRule() + + private lateinit var fragmentScenario: FragmentScenario + + private val defaultGrid = listOf( + -1, + 1, 2, 3, 4, + -2, + 3, 4, 5, 6, + -3, + 7, 8, 9, 10 + ) + + @Before + fun setup() { + launchFragment() + } + + @Test + fun testFocusIsAtFirstFocusablePosition() { + // given + setContent(items = defaultGrid, spanCount = 4) + + // when + waitForLayout() + + // then + assertFocusPosition(position = 1) + assertSelectedPosition(position = 1) + } + + @Test + fun testFocusUpWhenAboveItemDoesNotExistGoesToStartOfSpan() { + // given + setContent( + items = listOf( + -1, + 1, 2, 3, + -2, + 4, 5, 6, 7 + ), spanCount = 4 + ) + KeyEvents.pressDown(times = 1) + KeyEvents.pressRight(times = 3) + waitForIdleScrollState() + + assertFocusAndSelection(8) + + // when + KeyEvents.pressUp() + + // then + assertFocusAndSelection(position = 1) + } + + @Test + fun testLastRowReceivesFocus() { + // given + val headers = 3 + val spans = 5 + val items = buildList { + repeat(headers) { header -> + add(-(header + 1)) + repeat(spans) { + add(size) + } + } + } + setContent(items, spans) + + // when + KeyEvents.pressDown(times = headers, delay = 500) + waitForIdleScrollState() + + // then + assertFocusPosition(position = items.size - spans) + } + + @Test + fun testFastScrollWithIncompleteRows() { + // given + val headers = 10 + val spans = 5 + val items = buildList { + repeat(headers) { header -> + add(-(header + 1)) + repeat(spans - 1) { + add(size) + } + } + } + setContent(items, spans) + + // when + KeyEvents.pressDown(times = headers) + waitForIdleScrollState() + assertFocusAndSelection(items.size - spans + 1) + KeyEvents.pressUp(times = headers) + waitForIdleScrollState( + + ) + + // then + assertFocusAndSelection(1) + } + + @Test + fun testConsecutiveHeadersLeadToFocusSearch() { + // given + val headers = 10 + val spanCount = 4 + val items = buildList { + add(-1) + addAll(listOf(1, 2, 3, 4)) + repeat(headers - 1) { + add(-size) + } + addAll(listOf(5, 6, 7, 8)) + } + setContent( + items = items, + spanCount = spanCount + ) + + // when + KeyEvents.pressDown(times = headers) + waitForIdleScrollState() + assertFocusAndSelection(items.size - spanCount) + + // then + KeyEvents.pressUp(times = headers) + waitForIdleScrollState() + assertFocusAndSelection(1) + } + + @Test + fun testFocusStaysInTheSameSpan() { + // given + val spanCount = 4 + setContent(defaultGrid, spanCount) + val startPosition = 1 + + repeat(spanCount) { currentSpan -> + assertFocusPosition(position = startPosition + currentSpan) + KeyEvents.pressDown() + waitForIdleScrollState() + assertFocusAndSelection(position = startPosition + currentSpan + spanCount + 1) + KeyEvents.pressRight() + KeyEvents.pressUp() + waitForIdleScrollState() + } + + } + + @Test + fun testSearchFocusOutOfBoundsDoesNotCrash() { + // given + val spanCount = 4 + setContent(defaultGrid, spanCount) + + // when + KeyEvents.pressDown(times = defaultGrid.size) + waitForIdleScrollState() + + // then + assertFocusAndSelection(defaultGrid.size - spanCount) + } + + private fun setContent(items: List, spanCount: Int) { + onRecyclerView("Set content") { recyclerView -> + recyclerView.setSpanCount(spanCount) + recyclerView.addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = recyclerView.resources.getDimensionPixelSize( + R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + val gridAdapter = Adapter() + gridAdapter.submitList(items.toMutableList()) + recyclerView.setSpanSizeLookup(object : DpadSpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + val itemViewType = gridAdapter.getItemViewType(position) + return when (itemViewType) { + normalViewType -> 1 + else -> spanCount + } + } + }) + recyclerView.adapter = gridAdapter + } + } + + private class Adapter : TestAdapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SpanViewHolder { + return when (viewType) { + normalViewType -> GridViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.dpadrecyclerview_item_grid, parent, false) + ) + + else -> HeaderViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.dpadrecyclerview_grid_header, parent, false) + ) + } + } + + override fun onBindViewHolder(holder: SpanViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item > 0) { + normalViewType + } else { + headerViewType + } + } + + } + + private abstract class SpanViewHolder( + view: View + ) : RecyclerView.ViewHolder(view) { + abstract fun bind(item: Int) + } + + private class GridViewHolder(view: View) : SpanViewHolder(view) { + private val textView = view.findViewById(R.id.textView) + override fun bind(item: Int) { + textView.text = item.toString() + } + } + + private class HeaderViewHolder(view: View) : SpanViewHolder(view) { + private val textView = view.findViewById(R.id.textView) + override fun bind(item: Int) { + textView.text = "Header ${abs(item)}" + } + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = RTesting.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + +} diff --git a/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml new file mode 100644 index 00000000..d9f1956e --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_grid_header.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml new file mode 100644 index 00000000..0e83d3de --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_item_grid.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/dpadrecyclerview/src/androidTest/res/values/dimens.xml b/dpadrecyclerview/src/androidTest/res/values/dimens.xml new file mode 100644 index 00000000..6e1abf27 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/values/dimens.xml @@ -0,0 +1,19 @@ + + + + 16dp + \ No newline at end of file diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt index 38ce2417..7ffa8f49 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt @@ -26,14 +26,15 @@ import java.util.Collections import java.util.concurrent.Executors abstract class MutableListAdapter( - private val itemCallback: ItemCallback) : RecyclerView.Adapter() { + private val itemCallback: ItemCallback +) : RecyclerView.Adapter() { companion object { private val BACKGROUND_EXECUTOR = Executors.newFixedThreadPool(4) private val MAIN_THREAD_HANDLER = Handler(Looper.getMainLooper()) } - private var items : MutableList = Collections.emptyList() + private var items: MutableList = Collections.emptyList() private var currentVersion = 0 @SuppressLint("NotifyDataSetChanged")