From 124913bd9318a4ac0dae5c3a95a984b16d9f31b0 Mon Sep 17 00:00:00 2001 From: Duc Le Tran <36192582+anhanh11001@users.noreply.github.com> Date: Fri, 19 Feb 2021 06:30:55 +0700 Subject: [PATCH] Support for Paging3 (#1126) * Adds Support for paging v3. Adds controller which uses PagingData class and a cache class which uses this PagingData * ktlintFormat * Update epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt Co-authored-by: Osip Fatkullin * Update epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt Co-authored-by: Osip Fatkullin * Bump paging3 to the latest version - alpha13 * Add check before triggerLoadAround * Add simple implementation for getRefreshKey abstract function * Add kdoc for PagingDataEpoxyController * Use default diffing handler and default model building handler * Support retry(), refresh(), addLoadStateListener(), removeLoadStateListener() in PagingDataEpoxyController * Update epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt Co-authored-by: Osip Fatkullin Co-authored-by: farid Co-authored-by: Osip Fatkullin --- blessedDeps.gradle | 4 +- epoxy-paging3/build.gradle | 1 + .../epoxy/paging3/PagedDataModelCache.kt | 198 ++++++++++ .../paging3/PagingDataEpoxyController.kt | 162 +++++++++ .../com/airbnb/epoxy/paging3/DummyItem.kt | 16 + .../airbnb/epoxy/paging3/ListPagingSource.kt | 27 ++ .../epoxy/paging3/PagedDataModelCacheTest.kt | 343 ++++++++++++++++++ 7 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt create mode 100644 epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagingDataEpoxyController.kt create mode 100644 epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/DummyItem.kt create mode 100644 epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/ListPagingSource.kt create mode 100644 epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/PagedDataModelCacheTest.kt diff --git a/blessedDeps.gradle b/blessedDeps.gradle index bfd8990597..a5db041670 100644 --- a/blessedDeps.gradle +++ b/blessedDeps.gradle @@ -21,7 +21,7 @@ rootProject.ext.ANDROIDX_APPCOMPAT = "1.0.0" rootProject.ext.ANDROIDX_CARDVIEW = "1.0.0" rootProject.ext.ANDROIDX_LEGACY = "1.0.0" rootProject.ext.ANDROIDX_PAGING = "2.0.0" -rootProject.ext.ANDROIDX_PAGING3 = "3.0.0-alpha05" +rootProject.ext.ANDROIDX_PAGING3 = "3.0.0-alpha13" rootProject.ext.ANDROIDX_ROOM = "2.2.5" rootProject.ext.ANDROIDX_RUNTIME = "2.0.0" rootProject.ext.ANDROIDX_DATABINDING_COMPILER = "3.2.1" @@ -35,6 +35,7 @@ rootProject.ext.ANDROID_TEST_RUNNER = "1.0.2" rootProject.ext.SQUARE_JAVAPOET_VERSION = "1.11.1" rootProject.ext.SQUARE_KOTLINPOET_VERSION = "1.7.2" rootProject.ext.KOTLIN_COROUTINES_VERSION = "1.3.9" +rootProject.ext.KOTLIN_COROUTINES_TEST_VERSION = "1.4.1" rootProject.ext.GLIDE_VERSION = "4.9.0" rootProject.ext.ASSERTJ_VERSION = "1.7.1" @@ -55,6 +56,7 @@ rootProject.ext.INCAP_VERSION = "0.2" rootProject.ext.deps = [ kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION", kotlinCoroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$KOTLIN_COROUTINES_VERSION", + kotlinCoroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$KOTLIN_COROUTINES_TEST_VERSION", autoValue : "com.google.auto.value:auto-value:$AUTO_VALUE_VERSION", androidRuntime : "com.google.android:android:$ANDROID_RUNTIME_VERSION", androidAppcompat : "androidx.appcompat:appcompat:$ANDROIDX_APPCOMPAT", diff --git a/epoxy-paging3/build.gradle b/epoxy-paging3/build.gradle index 4059915526..f910930385 100644 --- a/epoxy-paging3/build.gradle +++ b/epoxy-paging3/build.gradle @@ -22,6 +22,7 @@ dependencies { testImplementation rootProject.deps.junit testImplementation rootProject.deps.robolectric testImplementation rootProject.deps.mockito + testImplementation rootProject.deps.kotlinCoroutinesTest androidTestImplementation rootProject.deps.junit androidTestImplementation rootProject.deps.androidArchCoreTesting androidTestImplementation rootProject.deps.androidTestRunner diff --git a/epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt b/epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt new file mode 100644 index 0000000000..fd92d6a221 --- /dev/null +++ b/epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagedDataModelCache.kt @@ -0,0 +1,198 @@ +package com.airbnb.epoxy.paging3 + +import android.os.Handler +import android.os.Looper +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.CombinedLoadStates +import androidx.paging.PagingData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import com.airbnb.epoxy.EpoxyModel +import kotlinx.coroutines.android.asCoroutineDispatcher + +/** + * A pagingData stream wrapper that caches models built for each item. It tracks changes in paged Data and caches + * models for each item when they are invalidated to avoid rebuilding models for the whole list when pagingData is + * updated. + * + * The pagingData submitted to this cache must be kept in sync with the model cache. To do this, + * the executor of the pagingData differ is set to the same thread as the model building handler. + * However, change notifications from the PageList happen on that list's notify executor which is + * out of our control, and we require the user to configure that properly, or an error is thrown. + * + * There are two special cases: + * + * 1. The first time models are built happens synchronously for immediate UI. In this case we don't + * use the model cache (to avoid data synchronization issues), but attempt to fill the cache with + * the models later. + * + * 2. When a pagingData is submitted it can trigger update callbacks synchronously. Since we don't control + * that thread we allow a special case of cache modification when a new list is being submitted, + * and all cache access is marked with @Synchronize to ensure safety when this happens. + */ +internal class PagedDataModelCache( + private val modelBuilder: (itemIndex: Int, item: T?) -> EpoxyModel<*>, + private val rebuildCallback: () -> Unit, + itemDiffCallback: DiffUtil.ItemCallback, + private val modelBuildingHandler: Handler +) { + /** + * Backing list for built models. This is a full array list that has null items for not yet build models. + */ + private val modelCache = arrayListOf?>() + + /** + * Tracks the last accessed position so that we can report it back to the paged list when models are built. + */ + private var lastPosition: Int? = null + + /** + * Set to true while a new list is being submitted, so that we can ignore the update callback + * thread restriction. + */ + private var inSubmitList: Boolean = false + + /** + * Observer for the PagedList changes that invalidates the model cache when data is updated. + */ + private val updateCallback = object : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) = synchronizedWithCache { + assertUpdateCallbacksAllowed() + (position until (position + count)).forEach { + modelCache[it] = null + } + rebuildCallback() + } + + override fun onMoved(fromPosition: Int, toPosition: Int) = synchronizedWithCache { + assertUpdateCallbacksAllowed() + val model = modelCache.removeAt(fromPosition) + modelCache.add(toPosition, model) + rebuildCallback() + } + + override fun onInserted(position: Int, count: Int) = synchronizedWithCache { + assertUpdateCallbacksAllowed() + repeat(count) { + modelCache.add(position, null) + } + rebuildCallback() + } + + override fun onRemoved(position: Int, count: Int) = synchronizedWithCache { + assertUpdateCallbacksAllowed() + repeat(count) { + modelCache.removeAt(position) + } + rebuildCallback() + } + + private fun synchronizedWithCache(block: () -> Unit) { + synchronized(this@PagedDataModelCache) { + block() + } + } + } + + /** + * Changes to the paged list must happen on the same thread as changes to the model cache to + * ensure they stay in sync. + * + * We can't force this to happen, and must instead rely on user's configuration, but we can alert + * when it is not configured correctly. + * + * An exception is thrown if the callback happens due to a new paged list being submitted, which can + * trigger a synchronous callback if the list goes from null to non null, or vice versa. + * + * Synchronization on [submitData] and other model cache access methods prevent issues when + * that happens. + */ + private fun assertUpdateCallbacksAllowed() { + require(inSubmitList || Looper.myLooper() == modelBuildingHandler.looper) { + "The notify executor for your PagedList must use the same thread as the model building handler set in PagedListEpoxyController.modelBuildingHandler" + } + } + + private val dispatcher = modelBuildingHandler.asCoroutineDispatcher() + private val asyncDiffer = AsyncPagingDataDiffer( + diffCallback = itemDiffCallback, + updateCallback = updateCallback, + mainDispatcher = dispatcher, + workerDispatcher = dispatcher + ) + + @Synchronized + suspend fun submitData(pagingData: PagingData) { + inSubmitList = true + asyncDiffer.submitData(pagingData) + inSubmitList = false + } + + @Synchronized + fun getModels(): List> { + val currentList = asyncDiffer.snapshot() + + // The first time models are built the EpoxyController does so synchronously, so that + // the UI can be ready immediately. To avoid concurrent modification issues with the PagedList + // and model cache we can't allow that first build to touch the cache. + if (Looper.myLooper() != modelBuildingHandler.looper) { + return currentList.mapIndexed { position, item -> + modelBuilder(position, item) + } + } + + (0 until modelCache.size).forEach { position -> + if (modelCache[position] == null) { + modelCache[position] = modelBuilder(position, currentList[position]) + } + } + + lastPosition?.let { + triggerLoadAround(it) + } + @Suppress("UNCHECKED_CAST") + return modelCache as List> + } + + /** + * Clears all cached models to force them to be rebuilt next time models are retrieved. + * This is posted to the model building thread to maintain data synchronicity. + */ + fun clearModels() { + modelBuildingHandler.post { + clearModelsSynchronized() + } + } + + fun retry() { + asyncDiffer.retry() + } + + fun refresh() { + asyncDiffer.refresh() + } + + fun addLoadStateListener(listener: (CombinedLoadStates) -> Unit) { + asyncDiffer.addLoadStateListener(listener) + } + + fun removeLoadStateListener(listener: (CombinedLoadStates) -> Unit) { + asyncDiffer.removeLoadStateListener(listener) + } + + @Synchronized + private fun clearModelsSynchronized() { + modelCache.fill(null) + } + + fun loadAround(position: Int) { + triggerLoadAround(position) + lastPosition = position + } + + private fun triggerLoadAround(position: Int) { + if (asyncDiffer.itemCount > 0) { + asyncDiffer.getItem(position.coerceIn(0, asyncDiffer.itemCount - 1)) + } + } +} diff --git a/epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagingDataEpoxyController.kt b/epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagingDataEpoxyController.kt new file mode 100644 index 0000000000..6189120706 --- /dev/null +++ b/epoxy-paging3/src/main/java/com/airbnb/epoxy/paging3/PagingDataEpoxyController.kt @@ -0,0 +1,162 @@ +package com.airbnb.epoxy.paging3 + +import android.annotation.SuppressLint +import android.os.Handler +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState +import androidx.paging.LoadType +import androidx.paging.LoadType.REFRESH +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.RemoteMediator +import androidx.recyclerview.widget.DiffUtil +import com.airbnb.epoxy.EpoxyAsyncUtil +import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.EpoxyViewHolder +import kotlinx.coroutines.ObsoleteCoroutinesApi + +/** + * An [EpoxyController] that can work with a [PagingData]. + * + * Internally, it caches the model for each item in the [PagingData]. You must override + * [buildItemModel] method to build the model for the given item. Since [PagingData] might include + * `null` items if placeholders are enabled, this method needs to handle `null` values in the list. + * + * By default, the model for each item is added to the model list. To change this behavior (to + * filter items or inject extra items), you can override [addModels] function and manually add built + * models. + * + * @param T The type of the item in the [PagingData]. + */ +@ObsoleteCoroutinesApi +abstract class PagingDataEpoxyController( + /** + * The handler to use for building models. By default this uses the main thread, but you can use + * [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do model building in the background. + * + * The notify thread of your PagedList (from setNotifyExecutor in the pagingData Builder) must be + * the same as this thread. Otherwise Epoxy will crash. + */ + modelBuildingHandler: Handler = defaultModelBuildingHandler, + /** + * The handler to use when calculating the diff between built model lists. + * By default this uses the main thread, but you can use + * [EpoxyAsyncUtil.getAsyncBackgroundHandler] to do diffing in the background. + */ + diffingHandler: Handler = defaultDiffingHandler, + /** + * [PagingDataEpoxyController] uses an [DiffUtil.ItemCallback] to detect changes between + * [PagingData]s. By default, it relies on simple object equality but you can provide a custom + * one if you don't use all fields in the object in your models. + */ + itemDiffCallback: DiffUtil.ItemCallback = DEFAULT_ITEM_DIFF_CALLBACK as DiffUtil.ItemCallback +) : EpoxyController(modelBuildingHandler, diffingHandler) { + // this is where we keep the already built models + private val modelCache = PagedDataModelCache( + modelBuilder = { pos, item -> + buildItemModel(pos, item) + }, + rebuildCallback = { + requestModelBuild() + }, + itemDiffCallback = itemDiffCallback, + modelBuildingHandler = modelBuildingHandler + ) + + final override fun buildModels() { + addModels(modelCache.getModels()) + } + + /** + * This function adds all built models to the adapter. You can override this method to add extra + * items into the model list or remove some. + */ + open fun addModels(models: List>) { + super.add(models) + } + + /** + * Builds the model for a given item. This must return a single model for each item. If you want + * to inject headers etc, you can override [addModels] function. + * + * If the `item` is `null`, you should provide the placeholder. If your [PagingData] is + * configured without placeholders, you don't need to handle the `null` case. + */ + abstract fun buildItemModel(currentPosition: Int, item: T?): EpoxyModel<*> + + override fun onModelBound( + holder: EpoxyViewHolder, + boundModel: EpoxyModel<*>, + position: Int, + previouslyBoundModel: EpoxyModel<*>? + ) { + // TODO the position may not be a good value if there are too many injected items. + modelCache.loadAround(position) + } + + /** + * Submit a new pagingData. + * + * A diff will be calculated between this pagingData and the previous pagingData so you may still get calls + * to [buildItemModel] with items from the previous PagingData. + */ + open suspend fun submitData(pagingData: PagingData) { + modelCache.submitData(pagingData) + } + + /** + * Retry any failed load requests that would result in a [LoadState.Error] update to this + * [PagingDataEpoxyController] + * + * [LoadState.Error] can be generated from two types of load requests: + * * [PagingSource.load] returning [PagingSource.LoadResult.Error] + * * [RemoteMediator.load] returning [RemoteMediator.MediatorResult.Error] + */ + fun retry() { + modelCache.retry() + } + + /** + * Refresh the data presented by this [PagingDataEpoxyController]. + * + * [refresh] triggers the creation of a new [PagingData] with a new instance of [PagingSource] + * to represent an updated snapshot of the backing dataset. If a [RemoteMediator] is set, + * calling [refresh] will also trigger a call to [RemoteMediator.load] with [LoadType] [REFRESH] + * to allow [RemoteMediator] to check for updates to the dataset backing [PagingSource]. + */ + fun refresh() { + modelCache.refresh() + } + + /** + * Add a [CombinedLoadStates] listener to observe the loading state of the current [PagingData]. + * + * As new [PagingData] generations are submitted and displayed, the listener will be notified to + * reflect the current [CombinedLoadStates]. + */ + fun addLoadStateListener(listener: (CombinedLoadStates) -> Unit) { + modelCache.addLoadStateListener(listener) + } + + /** + * Remove a previously registered [CombinedLoadStates] listener. + */ + fun removeLoadStateListener(listener: (CombinedLoadStates) -> Unit) { + modelCache.removeLoadStateListener(listener) + } + + companion object { + /** + * [PagingDataEpoxyController] calculates a diff on top of the PagingData to check which + * models are invalidated. + * This is the default [DiffUtil.ItemCallback] which uses object equality. + */ + val DEFAULT_ITEM_DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: Any, newItem: Any) = oldItem == newItem + } + } +} diff --git a/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/DummyItem.kt b/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/DummyItem.kt new file mode 100644 index 0000000000..02aec9c007 --- /dev/null +++ b/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/DummyItem.kt @@ -0,0 +1,16 @@ +package com.airbnb.epoxy.paging3 + +import androidx.recyclerview.widget.DiffUtil + +/** + * Dummy item for testing. + */ +data class DummyItem(val id: Int, val value: String) { + companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DummyItem, newItem: DummyItem) = oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: DummyItem, newItem: DummyItem) = oldItem == newItem + } + } +} diff --git a/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/ListPagingSource.kt b/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/ListPagingSource.kt new file mode 100644 index 0000000000..71a854aabb --- /dev/null +++ b/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/ListPagingSource.kt @@ -0,0 +1,27 @@ +package com.airbnb.epoxy.paging3 + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +class ListPagingSource( + private val coroutineContext: CoroutineContext, + private val defaultDelay: Long, + private val data: List +) : + PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return withContext(coroutineContext) { + delay(defaultDelay) + val key = params.key ?: 0 + LoadResult.Page(data.subList(key, key + params.loadSize), null, key + params.loadSize) + } + } + + override fun getRefreshKey(state: PagingState): Int? = null + + override val jumpingSupported: Boolean + get() = super.jumpingSupported +} diff --git a/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/PagedDataModelCacheTest.kt b/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/PagedDataModelCacheTest.kt new file mode 100644 index 0000000000..1f7b92905e --- /dev/null +++ b/epoxy-paging3/src/test/java/com/airbnb/epoxy/paging3/PagedDataModelCacheTest.kt @@ -0,0 +1,343 @@ +package com.airbnb.epoxy.paging3 + +import android.view.View +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.airbnb.epoxy.EpoxyController +import com.airbnb.epoxy.EpoxyModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.hamcrest.CoreMatchers +import org.hamcrest.MatcherAssert +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.LooperMode +import kotlin.coroutines.CoroutineContext + +@ExperimentalCoroutinesApi +@ObsoleteCoroutinesApi +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.LEGACY) +class PagedDataModelCacheTest { + /** + * test dispatcher used for controlling paging source + * */ + private val testDispatcher = TestCoroutineDispatcher() + + /** + * Simple mode builder for [DummyItem] + */ + private var modelBuildCounter = 0 + private val modelBuilder: (Int, DummyItem?) -> EpoxyModel<*> = { pos, item -> + modelBuildCounter++ + if (item == null) { + FakePlaceholderModel(pos) + } else { + FakeModel(item) + } + } + + /** + * Number of times a rebuild is requested + */ + private var rebuildCounter = 0 + private val rebuildCallback: () -> Unit = { + rebuildCounter++ + } + + private val pagedDataModelCache = + PagedDataModelCache( + modelBuilder = modelBuilder, + rebuildCallback = rebuildCallback, + itemDiffCallback = DummyItem.DIFF_CALLBACK, + modelBuildingHandler = EpoxyController.defaultModelBuildingHandler + ) + + @Test + fun empty() { + MatcherAssert.assertThat(pagedDataModelCache.getModels(), CoreMatchers.`is`(emptyList())) + } + + @Test + fun simple() = runBlocking { + val items = createDummyItems(PAGE_SIZE) + val pagedData = createPagedData(items) + pagedDataModelCache.submitData(pagedData) + assertModelDummyItems(items) + assertAndResetRebuildModels() + } + + @Test + fun partialLoad() = runBlockingTest { + val items = createDummyItems(INITIAL_LOAD_SIZE + PAGE_SIZE) + val pager = createPager(testDispatcher, items) + val deferred = async { + pager.flow.collect { + pagedDataModelCache.submitData(it) + } + } + // advance in time to create first page of data + testDispatcher.advanceTimeBy(DEFAULT_DELAY) + + // wait for pagedDataModelCache submits data + delay(2000) + + assertModelDummyItems(items.subList(0, INITIAL_LOAD_SIZE)) + assertAndResetRebuildModels() + pagedDataModelCache.loadAround(INITIAL_LOAD_SIZE - 1) + assertModelDummyItems(items.subList(0, INITIAL_LOAD_SIZE)) + MatcherAssert.assertThat(rebuildCounter, CoreMatchers.`is`(0)) + // advance in time to create second page of data + testDispatcher.advanceTimeBy(DEFAULT_DELAY) + delay(2000) + assertModelDummyItems(items) + assertAndResetRebuildModels() + + deferred.cancel() + } + + @Test + fun deletion() { + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.removeAt(3) + }, + expectedModels = models.toMutableList().also { + it.removeAt(3) + } + ) + } + } + + @Test + fun deletion_range() { + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.removeAll(items.subList(3, 5)) + }, + expectedModels = models.toMutableList().also { + it.removeAll(models.subList(3, 5)) + } + ) + } + } + + @Test + fun append() { + val newDummyItem = DummyItem(id = 100, value = "newDummyItem") + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.add(newDummyItem) + }, + expectedModels = models.toMutableList().also { + it.add(newDummyItem) + } + ) + } + } + + @Test + fun append_many() { + val newDummyItems = (100 until 105).map { + DummyItem(id = it, value = "newDummyItem $it") + } + testListUpdate { items, models -> + Modification( + newList = items.copyToMutable().also { + it.addAll(newDummyItems) + }, + expectedModels = models.toMutableList().also { + it.addAll(newDummyItems) + } + ) + } + } + + @Test + fun insert() { + testListUpdate { items, models -> + val newDummyItem = + DummyItem(id = 100, value = "item x") + Modification( + newList = items.copyToMutable().also { + it.add(5, newDummyItem) + }, + expectedModels = models.toMutableList().also { + it.add(5, newDummyItem) + } + ) + } + } + + @Test + fun insert_many() { + testListUpdate { items, models -> + val newDummyItems = (100 until 105).map { + DummyItem(id = it, value = "newDummyItem $it") + } + Modification( + newList = items.copyToMutable().also { + it.addAll(5, newDummyItems) + }, + expectedModels = models.toMutableList().also { + it.addAll(5, newDummyItems) + } + ) + } + } + + @Test + fun move() { + testListUpdate { items, models -> + Modification( + newList = items.toMutableList().also { + it.add(3, it.removeAt(5)) + }, + expectedModels = models.toMutableList().also { + it.add(3, it.removeAt(5)) + } + ) + } + } + + @Test + fun move_multiple() { + testListUpdate { items, models -> + Modification( + newList = items.toMutableList().also { + it.add(3, it.removeAt(5)) + it.add(1, it.removeAt(8)) + }, + expectedModels = models.toMutableList().also { + it.add(3, it.removeAt(5)) + it.add(1, it.removeAt(8)) + } + ) + } + } + + @Test + fun clear() = runBlocking { + val items = createDummyItems(PAGE_SIZE) + val pagedData = createPagedData(items) + pagedDataModelCache.submitData(pagedData) + pagedDataModelCache.getModels() + assertAndResetModelBuild() + pagedDataModelCache.clearModels() + pagedDataModelCache.getModels() + assertAndResetModelBuild() + } + + private fun assertAndResetModelBuild() { + MatcherAssert.assertThat(modelBuildCounter > 0, CoreMatchers.`is`(true)) + modelBuildCounter = 0 + } + + private fun assertAndResetRebuildModels() { + MatcherAssert.assertThat(rebuildCounter > 0, CoreMatchers.`is`(true)) + rebuildCounter = 0 + } + + /** + * Helper method to verify multiple list update scenarios + */ + private fun testListUpdate(update: (items: List, models: List) -> Modification) = + runBlocking { + val items = createDummyItems(PAGE_SIZE) + pagedDataModelCache.submitData(createPagedData(items)) + val (updatedList, expectedModels) = update(items, collectModelDummyItems()) + pagedDataModelCache.submitData(createPagedData(updatedList)) + + val updatedModels = collectModelDummyItems() + MatcherAssert.assertThat(updatedModels.size, CoreMatchers.`is`(expectedModels.size)) + updatedModels.forEachIndexed { index, item -> + when (item) { + is DummyItem -> { + assertEquals(item, expectedModels[index]) + } + else -> { + MatcherAssert.assertThat(item, CoreMatchers.`is`(expectedModels[index])) + } + } + } + } + + private fun assertModelDummyItems(expected: List) { + MatcherAssert.assertThat(collectModelDummyItems(), CoreMatchers.`is`(expected)) + } + + @Suppress("IMPLICIT_CAST_TO_ANY") + private fun collectModelDummyItems(): List { + return pagedDataModelCache.getModels().map { + when (it) { + is FakeModel -> it.item + is FakePlaceholderModel -> it.pos + else -> null + } + } + } + + private fun createDummyItems(cnt: Int): List { + return (0 until cnt).map { + DummyItem(id = it, value = "DummyItem $it") + } + } + + private fun createPagedData(items: List): PagingData { + return PagingData.from(items) + } + + private fun createPager( + coroutineContext: CoroutineContext, + items: List + ): Pager { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = INITIAL_LOAD_SIZE, + enablePlaceholders = true, + ), + initialKey = null, + pagingSourceFactory = { + ListPagingSource(coroutineContext, DEFAULT_DELAY, items) + } + ) + } + + class FakePlaceholderModel(val pos: Int) : EpoxyModel(-pos.toLong()) { + override fun getDefaultLayout() = throw NotImplementedError("not needed for this test") + } + + class FakeModel(val item: DummyItem) : EpoxyModel(item.id.toLong()) { + override fun getDefaultLayout() = throw NotImplementedError("not needed for this test") + } + + data class Modification( + val newList: List, + val expectedModels: List + ) + + private fun List.copyToMutable(): MutableList { + return mapTo(arrayListOf()) { + it.copy() + } + } + + companion object { + private const val PAGE_SIZE = 10 + private const val INITIAL_LOAD_SIZE = PAGE_SIZE * 2 + private const val DEFAULT_DELAY = 10000L + } +}