1 /*
<lambda>null2  * Copyright 2021 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 androidx.paging
18 
19 import android.content.Context
20 import android.view.View
21 import android.view.View.MeasureSpec.EXACTLY
22 import android.view.ViewGroup
23 import androidx.recyclerview.widget.AdapterListUpdateCallback
24 import androidx.recyclerview.widget.DiffUtil
25 import androidx.recyclerview.widget.LinearLayoutManager
26 import androidx.recyclerview.widget.ListUpdateCallback
27 import androidx.recyclerview.widget.RecyclerView
28 import androidx.test.core.app.ApplicationProvider
29 import androidx.test.ext.junit.runners.AndroidJUnit4
30 import androidx.test.filters.LargeTest
31 import com.google.common.truth.Truth.assertThat
32 import com.google.common.truth.Truth.assertWithMessage
33 import kotlin.random.Random
34 import org.junit.Before
35 import org.junit.Test
36 import org.junit.runner.RunWith
37 
38 /**
39  * For some tests, this test uses a real recyclerview with a real adapter to serve as an integration
40  * test so that we can validate all updates and state restorations after updates.
41  */
42 @RunWith(AndroidJUnit4::class)
43 @LargeTest
44 class PlaceholderPaddedListDiffWithRecyclerViewTest {
45     private lateinit var context: Context
46     private lateinit var recyclerView: RecyclerView
47     private lateinit var adapter: PlaceholderPaddedListAdapter
48 
49     @Before
50     fun init() {
51         context = ApplicationProvider.getApplicationContext()
52         recyclerView =
53             RecyclerView(context).also {
54                 it.layoutManager = LinearLayoutManager(context)
55                 it.itemAnimator = null
56             }
57         adapter = PlaceholderPaddedListAdapter()
58         recyclerView.adapter = adapter
59     }
60 
61     // this is no different that init but reads better in tests to have a reset method
62     private fun reset() {
63         init()
64     }
65 
66     private fun measureAndLayout() {
67         recyclerView.measure(EXACTLY or 100, EXACTLY or RV_HEIGHT)
68         recyclerView.layout(0, 0, 100, RV_HEIGHT)
69     }
70 
71     @Test
72     fun basic() {
73         val storage =
74             PlaceholderPaddedStorage(
75                 placeholdersBefore = 0,
76                 data = createItems(0, 10),
77                 placeholdersAfter = 0
78             )
79         adapter.setItems(storage)
80         measureAndLayout()
81         val snapshot = captureUISnapshot()
82         assertThat(snapshot)
83             .containsExactlyElementsIn(
84                 createExpectedSnapshot(
85                     firstItemTopOffset = 0,
86                     startItemIndex = 0,
87                     backingList = storage
88                 )
89             )
90     }
91 
92     @Test
93     fun distinctLists_fullyOverlappingRange() {
94         val pre =
95             PlaceholderPaddedStorage(
96                 placeholdersBefore = 10,
97                 data = createItems(startId = 10, count = 8),
98                 placeholdersAfter = 30
99             )
100         val post =
101             PlaceholderPaddedStorage(
102                 placeholdersBefore = 10,
103                 data = createItems(startId = 100, count = 8),
104                 placeholdersAfter = 30
105             )
106         distinctListTest_withVariousInitialPositions(
107             pre = pre,
108             post = post,
109         )
110     }
111 
112     @Test
113     fun distinctLists_loadedBefore_or_After() {
114         val pre =
115             PlaceholderPaddedStorage(
116                 placeholdersBefore = 10,
117                 data = createItems(startId = 10, count = 10),
118                 placeholdersAfter = 10
119             )
120         val post =
121             PlaceholderPaddedStorage(
122                 placeholdersBefore = 5,
123                 data = createItems(startId = 5, count = 5),
124                 placeholdersAfter = 20
125             )
126         distinctListTest_withVariousInitialPositions(pre = pre, post = post)
127     }
128 
129     @Test
130     fun distinctLists_partiallyOverlapping() {
131         val pre =
132             PlaceholderPaddedStorage(
133                 placeholdersBefore = 10,
134                 data = createItems(startId = 0, count = 8),
135                 placeholdersAfter = 30
136             )
137         val post =
138             PlaceholderPaddedStorage(
139                 placeholdersBefore = 15,
140                 data = createItems(startId = 100, count = 8),
141                 placeholdersAfter = 30
142             )
143         distinctListTest_withVariousInitialPositions(
144             pre = pre,
145             post = post,
146         )
147     }
148 
149     @Test
150     fun distinctLists_fewerItemsLoaded_withMorePlaceholdersBefore() {
151         val pre =
152             PlaceholderPaddedStorage(
153                 placeholdersBefore = 10,
154                 data = createItems(startId = 10, count = 8),
155                 placeholdersAfter = 30
156             )
157         val post =
158             PlaceholderPaddedStorage(
159                 placeholdersBefore = 15,
160                 data = createItems(startId = 100, count = 3),
161                 placeholdersAfter = 30
162             )
163         distinctListTest_withVariousInitialPositions(
164             pre = pre,
165             post = post,
166         )
167     }
168 
169     @Test
170     fun distinctLists_noPlaceholdersLeft() {
171         val pre =
172             PlaceholderPaddedStorage(
173                 placeholdersBefore = 10,
174                 data = createItems(startId = 10, count = 8),
175                 placeholdersAfter = 30
176             )
177         val post =
178             PlaceholderPaddedStorage(
179                 placeholdersBefore = 0,
180                 data = createItems(startId = 100, count = 3),
181                 placeholdersAfter = 0
182             )
183         distinctListTest_withVariousInitialPositions(
184             pre = pre,
185             post = post,
186         )
187     }
188 
189     @Test
190     fun distinctLists_moreItemsLoaded() {
191         val pre =
192             PlaceholderPaddedStorage(
193                 placeholdersBefore = 10,
194                 data = createItems(startId = 10, count = 3),
195                 placeholdersAfter = 30
196             )
197         val post =
198             PlaceholderPaddedStorage(
199                 placeholdersBefore = 10,
200                 data = createItems(startId = 100, count = 8),
201                 placeholdersAfter = 30
202             )
203         distinctListTest_withVariousInitialPositions(
204             pre = pre,
205             post = post,
206         )
207     }
208 
209     @Test
210     fun distinctLists_moreItemsLoaded_andAlsoMoreOffset() {
211         val pre =
212             PlaceholderPaddedStorage(
213                 placeholdersBefore = 10,
214                 data = createItems(startId = 10, count = 3),
215                 placeholdersAfter = 30
216             )
217         val post =
218             PlaceholderPaddedStorage(
219                 placeholdersBefore = 15,
220                 data = createItems(startId = 100, count = 8),
221                 placeholdersAfter = 30
222             )
223         distinctListTest_withVariousInitialPositions(
224             pre = pre,
225             post = post,
226         )
227     }
228 
229     @Test
230     fun distinctLists_expandShrink() {
231         val pre =
232             PlaceholderPaddedStorage(
233                 placeholdersBefore = 10,
234                 data = createItems(10, 10),
235                 placeholdersAfter = 20
236             )
237         val post =
238             PlaceholderPaddedStorage(
239                 placeholdersBefore = 0,
240                 data = createItems(100, 1),
241                 placeholdersAfter = 0
242             )
243         distinctListTest_withVariousInitialPositions(
244             pre = pre,
245             post = post,
246         )
247     }
248 
249     /** Runs a state restoration test with various "current scroll positions". */
250     private fun distinctListTest_withVariousInitialPositions(
251         pre: PlaceholderPaddedStorage,
252         post: PlaceholderPaddedStorage
253     ) {
254         // try restoring positions in different list states
255         val minSize = minOf(pre.size, post.size)
256         val lastTestablePosition = (minSize - (RV_HEIGHT / ITEM_HEIGHT)).coerceAtLeast(0)
257         (0..lastTestablePosition).forEach { initialPos ->
258             distinctListTest(
259                 pre = pre,
260                 post = post,
261                 initialListPos = initialPos,
262             )
263             reset()
264             distinctListTest(
265                 pre = post, // intentional, we are trying to test going in reverse direction
266                 post = pre,
267                 initialListPos = initialPos,
268             )
269             reset()
270         }
271     }
272 
273     @Test
274     fun distinctLists_visibleRangeRemoved() {
275         val pre =
276             PlaceholderPaddedStorage(
277                 placeholdersBefore = 10,
278                 data = createItems(10, 10),
279                 placeholdersAfter = 30
280             )
281         val post =
282             PlaceholderPaddedStorage(
283                 placeholdersBefore = 0,
284                 data = createItems(100, 4),
285                 placeholdersAfter = 20
286             )
287         swapListTest(
288             pre = pre,
289             post = post,
290             preSwapAction = { recyclerView.scrollBy(0, 30 * ITEM_HEIGHT) },
291             validate = { _, newSnapshot ->
292                 assertThat(newSnapshot)
293                     .containsExactlyElementsIn(
294                         createExpectedSnapshot(
295                             startItemIndex = post.size - RV_HEIGHT / ITEM_HEIGHT,
296                             backingList = post
297                         )
298                     )
299             }
300         )
301     }
302 
303     @Test
304     fun distinctLists_validateDiff() {
305         val pre =
306             PlaceholderPaddedStorage(
307                 placeholdersBefore = 10,
308                 data = createItems(10, 10), // their positions won't be in the new list
309                 placeholdersAfter = 20
310             )
311         val post =
312             PlaceholderPaddedStorage(
313                 placeholdersBefore = 0,
314                 data = createItems(100, 1),
315                 placeholdersAfter = 0
316             )
317         updateDiffTest(pre, post)
318     }
319 
320     @Test
321     @LargeTest
322     fun random_distinctListTest() {
323         // this is a random test but if it fails, the exception will have enough information to
324         // create an isolated test
325         val rand = Random(System.nanoTime())
326         fun randomPlaceholderPaddedStorage(startId: Int) =
327             PlaceholderPaddedStorage(
328                 placeholdersBefore = rand.nextInt(0, 20),
329                 data = createItems(startId = startId, count = rand.nextInt(0, 20)),
330                 placeholdersAfter = rand.nextInt(0, 20)
331             )
332         repeat(RANDOM_TEST_REPEAT_SIZE) {
333             updateDiffTest(
334                 pre = randomPlaceholderPaddedStorage(0),
335                 post = randomPlaceholderPaddedStorage(1_000)
336             )
337         }
338     }
339 
340     @Test
341     fun continuousMatch_1() {
342         val pre =
343             PlaceholderPaddedStorage(
344                 placeholdersBefore = 4,
345                 data = createItems(startId = 0, count = 16),
346                 placeholdersAfter = 1
347             )
348         val post =
349             PlaceholderPaddedStorage(
350                 placeholdersBefore = 1,
351                 data = createItems(startId = 13, count = 4),
352                 placeholdersAfter = 19
353             )
354         updateDiffTest(pre, post)
355     }
356 
357     @Test
358     fun continuousMatch_2() {
359         val pre =
360             PlaceholderPaddedStorage(
361                 placeholdersBefore = 6,
362                 data = createItems(startId = 0, count = 9),
363                 placeholdersAfter = 19
364             )
365         val post =
366             PlaceholderPaddedStorage(
367                 placeholdersBefore = 14,
368                 data = createItems(startId = 4, count = 3),
369                 placeholdersAfter = 11
370             )
371         updateDiffTest(pre, post)
372     }
373 
374     @Test
375     fun continuousMatch_3() {
376         val pre =
377             PlaceholderPaddedStorage(
378                 placeholdersBefore = 11,
379                 data = createItems(startId = 0, count = 4),
380                 placeholdersAfter = 6
381             )
382         val post =
383             PlaceholderPaddedStorage(
384                 placeholdersBefore = 7,
385                 data = createItems(startId = 0, count = 1),
386                 placeholdersAfter = 11
387             )
388         updateDiffTest(pre, post)
389     }
390 
391     @Test
392     fun continuousMatch_4() {
393         val pre =
394             PlaceholderPaddedStorage(
395                 placeholdersBefore = 4,
396                 data = createItems(startId = 0, count = 15),
397                 placeholdersAfter = 18
398             )
399         val post =
400             PlaceholderPaddedStorage(
401                 placeholdersBefore = 11,
402                 data = createItems(startId = 5, count = 17),
403                 placeholdersAfter = 9
404             )
405         updateDiffTest(pre, post)
406     }
407 
408     @Test
409     @LargeTest
410     fun randomTest_withContinuousMatch() {
411         randomContinuousMatchTest(shuffle = false)
412     }
413 
414     @Test
415     @LargeTest
416     fun randomTest_withContinuousMatch_withShuffle() {
417         randomContinuousMatchTest(shuffle = true)
418     }
419 
420     /**
421      * Tests that if two lists have some overlaps, we dispatch the right diff events. It can also
422      * optionally shuffle the lists.
423      */
424     private fun randomContinuousMatchTest(shuffle: Boolean) {
425         // this is a random test but if it fails, the exception will have enough information to
426         // create an isolated test
427         val rand = Random(System.nanoTime())
428         fun randomPlaceholderPaddedStorage(startId: Int) =
429             PlaceholderPaddedStorage(
430                 placeholdersBefore = rand.nextInt(0, 20),
431                 data =
432                     createItems(startId = startId, count = rand.nextInt(0, 20)).let {
433                         if (shuffle) it.shuffled() else it
434                     },
435                 placeholdersAfter = rand.nextInt(0, 20)
436             )
437         repeat(RANDOM_TEST_REPEAT_SIZE) {
438             val pre = randomPlaceholderPaddedStorage(0)
439             val post =
440                 randomPlaceholderPaddedStorage(
441                     startId =
442                         if (pre.dataCount > 0) {
443                             pre.getItem(rand.nextInt(pre.dataCount)).id
444                         } else {
445                             0
446                         }
447                 )
448             updateDiffTest(pre = pre, post = post)
449         }
450     }
451 
452     /** Validates that the update events between [pre] and [post] are correct. */
453     private fun updateDiffTest(pre: PlaceholderPaddedStorage, post: PlaceholderPaddedStorage) {
454         val callback = ValidatingListUpdateCallback(pre, post)
455         val diffResult = pre.computeDiff(post, PlaceholderPaddedListItem.CALLBACK)
456         pre.dispatchDiff(callback, post, diffResult)
457         callback.validateRunningListAgainst()
458     }
459 
460     private fun distinctListTest(
461         pre: PlaceholderPaddedStorage,
462         post: PlaceholderPaddedStorage,
463         initialListPos: Int,
464         finalListPos: Int = initialListPos
465     ) {
466         // try with various initial list positioning.
467         // in every case, we should preserve our position
468         swapListTest(
469             pre = pre,
470             post = post,
471             preSwapAction = { recyclerView.scrollBy(0, initialListPos * ITEM_HEIGHT) },
472             validate = { _, snapshot ->
473                 assertWithMessage(
474                         """
475                     initial pos: $initialListPos
476                     expected final pos: $finalListPos
477                     pre: $pre
478                     post: $post
479                     """
480                             .trimIndent()
481                     )
482                     .that(snapshot)
483                     .containsExactlyElementsIn(
484                         createExpectedSnapshot(startItemIndex = finalListPos, backingList = post)
485                     )
486             }
487         )
488     }
489 
490     /**
491      * Helper function to run tests where we submit the [pre] list, run [preSwapAction] (where it
492      * can scroll etc) then submit [post] list, run [postSwapAction] and then call [validate] with
493      * UI snapshots.
494      */
495     private fun swapListTest(
496         pre: PlaceholderPaddedStorage,
497         post: PlaceholderPaddedStorage,
498         preSwapAction: () -> Unit = {},
499         postSwapAction: () -> Unit = {},
500         validate: (preCapture: List<UIItemSnapshot>, postCapture: List<UIItemSnapshot>) -> Unit
501     ) {
502         adapter.setItems(pre)
503         measureAndLayout()
504         preSwapAction()
505         val preSnapshot = captureUISnapshot()
506         adapter.setItems(post)
507         postSwapAction()
508         measureAndLayout()
509         val postSnapshot = captureUISnapshot()
510         validate(preSnapshot, postSnapshot)
511     }
512 
513     /** Captures positions and data of each visible item in the RecyclerView. */
514     private fun captureUISnapshot(): List<UIItemSnapshot> {
515         return (0 until recyclerView.childCount).mapNotNull { childPos ->
516             val view = recyclerView.getChildAt(childPos)!!
517             if (view.top < RV_HEIGHT && view.bottom > 0) {
518                 val viewHolder =
519                     recyclerView.getChildViewHolder(view) as PlaceholderPaddedListViewHolder
520                 UIItemSnapshot(
521                     top = view.top,
522                     boundItem = viewHolder.boundItem,
523                     boundPos = viewHolder.boundPos
524                 )
525             } else {
526                 null
527             }
528         }
529     }
530 
531     /** Custom adapter class that also validates its update events to ensure they are correct. */
532     private class PlaceholderPaddedListAdapter :
533         RecyclerView.Adapter<PlaceholderPaddedListViewHolder>() {
534         private var items: PlaceholderPaddedList<PlaceholderPaddedListItem>? = null
535 
536         fun setItems(items: PlaceholderPaddedList<PlaceholderPaddedListItem>) {
537             val previousItems = this.items
538             val myItems = this.items
539             if (myItems == null) {
540                 notifyItemRangeInserted(0, items.size)
541             } else {
542                 val diff = myItems.computeDiff(items, PlaceholderPaddedListItem.CALLBACK)
543                 val diffObserver = TrackingAdapterObserver(previousItems, items)
544                 registerAdapterDataObserver(diffObserver)
545                 val callback = AdapterListUpdateCallback(this)
546                 myItems.dispatchDiff(callback, items, diff)
547                 unregisterAdapterDataObserver(diffObserver)
548                 diffObserver.validateRunningListAgainst()
549             }
550             this.items = items
551         }
552 
553         override fun onCreateViewHolder(
554             parent: ViewGroup,
555             viewType: Int
556         ): PlaceholderPaddedListViewHolder {
557             return PlaceholderPaddedListViewHolder(parent.context).also {
558                 it.itemView.layoutParams =
559                     RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, ITEM_HEIGHT)
560             }
561         }
562 
563         override fun onBindViewHolder(holder: PlaceholderPaddedListViewHolder, position: Int) {
564             val item = items?.get(position)
565             holder.boundItem = item
566             holder.boundPos = position
567         }
568 
569         override fun getItemCount(): Int {
570             return items?.size ?: 0
571         }
572     }
573 
574     private data class PlaceholderPaddedListItem(val id: Int, val value: String) {
575         companion object {
576             val CALLBACK =
577                 object : DiffUtil.ItemCallback<PlaceholderPaddedListItem>() {
578                     override fun areItemsTheSame(
579                         oldItem: PlaceholderPaddedListItem,
580                         newItem: PlaceholderPaddedListItem
581                     ): Boolean {
582                         return oldItem.id == newItem.id
583                     }
584 
585                     override fun areContentsTheSame(
586                         oldItem: PlaceholderPaddedListItem,
587                         newItem: PlaceholderPaddedListItem
588                     ): Boolean {
589                         return oldItem == newItem
590                     }
591                 }
592         }
593     }
594 
595     private class PlaceholderPaddedListViewHolder(context: Context) :
596         RecyclerView.ViewHolder(View(context)) {
597         var boundItem: PlaceholderPaddedListItem? = null
598         var boundPos: Int = -1
599 
600         override fun toString(): String {
601             return "VH[$boundPos , $boundItem]"
602         }
603     }
604 
605     private data class UIItemSnapshot(
606         // top coordinate of the item
607         val top: Int,
608         // the item it is bound to, unless it was a placeholder
609         val boundItem: PlaceholderPaddedListItem?,
610         // the position it was bound to
611         val boundPos: Int
612     )
613 
614     private class PlaceholderPaddedStorage(
615         override val placeholdersBefore: Int,
616         private val data: List<PlaceholderPaddedListItem>,
617         override val placeholdersAfter: Int
618     ) : PlaceholderPaddedList<PlaceholderPaddedListItem> {
619         private val stringRepresentation by lazy {
620             """
621             $placeholdersBefore:${data.size}:$placeholdersAfter
622             $data
623             """
624                 .trimIndent()
625         }
626 
627         override fun getItem(index: Int): PlaceholderPaddedListItem = data[index]
628 
629         override val size: Int
630             get() = placeholdersBefore + data.size + placeholdersAfter
631 
632         override val dataCount: Int
633             get() = data.size
634 
635         override fun toString() = stringRepresentation
636     }
637 
638     private fun createItems(startId: Int, count: Int): List<PlaceholderPaddedListItem> {
639         return (startId until startId + count).map {
640             PlaceholderPaddedListItem(id = it, value = "$it")
641         }
642     }
643 
644     /** Creates an expected UI snapshot based on the given list and scroll position / offset. */
645     private fun createExpectedSnapshot(
646         firstItemTopOffset: Int = 0,
647         startItemIndex: Int,
648         backingList: PlaceholderPaddedList<PlaceholderPaddedListItem>
649     ): List<UIItemSnapshot> {
650         check(firstItemTopOffset <= 0) { "first item offset should not be negative" }
651         var remainingHeight = RV_HEIGHT - firstItemTopOffset
652         var pos = startItemIndex
653         var top = firstItemTopOffset
654         val result = mutableListOf<UIItemSnapshot>()
655         while (remainingHeight > 0 && pos < backingList.size) {
656             result.add(UIItemSnapshot(top = top, boundItem = backingList.get(pos), boundPos = pos))
657             top += ITEM_HEIGHT
658             remainingHeight -= ITEM_HEIGHT
659             pos++
660         }
661         return result
662     }
663 
664     /**
665      * A ListUpdateCallback implementation that tracks all change notifications and then validate
666      * that a) changes are correct b) no unnecessary events are dispatched (e.g. dispatching change
667      * for an item then removing it)
668      */
669     private class ValidatingListUpdateCallback<T>(
670         previousList: PlaceholderPaddedList<T>?,
671         private val newList: PlaceholderPaddedList<T>
672     ) : ListUpdateCallback {
673         // used in assertion messages
674         val msg =
675             """
676                 oldList: $previousList
677                 newList: $newList
678         """
679                 .trimIndent()
680 
681         // all changes are applied to this list, at the end, we'll validate against the new list
682         // to ensure all updates made sense and no unnecessary updates are made
683         val runningList: MutableList<ListSnapshotItem> =
684             previousList?.createSnapshot() ?: mutableListOf()
685 
686         private val size: Int
687             get() = runningList.size
688 
689         private fun Int.assertWithinBounds() {
690             assertWithMessage(msg).that(this).isAtLeast(0)
691             assertWithMessage(msg).that(this).isAtMost(size)
692         }
693 
694         override fun onInserted(position: Int, count: Int) {
695             position.assertWithinBounds()
696             assertWithMessage(msg).that(count).isAtLeast(1)
697             repeat(count) { runningList.add(position, ListSnapshotItem.Inserted) }
698         }
699 
700         override fun onRemoved(position: Int, count: Int) {
701             position.assertWithinBounds()
702             (position + count).assertWithinBounds()
703             assertWithMessage(msg).that(count).isAtLeast(1)
704             (position until position + count).forEach { pos ->
705                 assertWithMessage(
706                         "$msg\nshouldn't be removing an item that already got a change event" +
707                             " pos: $pos , ${runningList[pos]}"
708                     )
709                     .that(runningList[pos].isOriginalItem())
710                     .isTrue()
711             }
712             repeat(count) { runningList.removeAt(position) }
713         }
714 
715         override fun onMoved(fromPosition: Int, toPosition: Int) {
716             fromPosition.assertWithinBounds()
717             toPosition.assertWithinBounds()
718             runningList.add(toPosition, runningList.removeAt(fromPosition))
719         }
720 
721         override fun onChanged(position: Int, count: Int, payload: Any?) {
722             position.assertWithinBounds()
723             (position + count).assertWithinBounds()
724             assertWithMessage(msg).that(count).isAtLeast(1)
725             (position until position + count).forEach { pos ->
726                 // make sure we don't dispatch overlapping updates
727                 assertWithMessage(
728                         "$msg\nunnecessary change event for position $pos $payload " +
729                             "${runningList[pos]}"
730                     )
731                     .that(runningList[pos].isOriginalItem())
732                     .isTrue()
733                 if (
734                     payload == DiffingChangePayload.PLACEHOLDER_TO_ITEM ||
735                         payload == DiffingChangePayload.PLACEHOLDER_POSITION_CHANGE
736                 ) {
737                     assertWithMessage(msg)
738                         .that(runningList[pos])
739                         .isInstanceOf(ListSnapshotItem.Placeholder::class.java)
740                 } else {
741                     assertWithMessage(msg)
742                         .that(runningList[pos])
743                         .isInstanceOf(ListSnapshotItem.Item::class.java)
744                 }
745                 runningList[pos] =
746                     ListSnapshotItem.Changed(payload = payload as? DiffingChangePayload)
747             }
748         }
749 
750         fun validateRunningListAgainst() {
751             // check for size first
752             assertWithMessage(msg).that(size).isEqualTo(newList.size)
753             val newListSnapshot = newList.createSnapshot()
754             runningList.forEachIndexed { index, listSnapshotItem ->
755                 val newListItem = newListSnapshot[index]
756                 listSnapshotItem.assertReplacement(msg, newListItem)
757                 if (!listSnapshotItem.isOriginalItem()) {
758                     // if it changed, replace from new snapshot
759                     runningList[index] = newListSnapshot[index]
760                 }
761             }
762             // now after this, each list must be exactly equal, if not, something is wrong
763             assertWithMessage(msg).that(runningList).containsExactlyElementsIn(newListSnapshot)
764         }
765     }
766 
767     private class TrackingAdapterObserver<T>(
768         previousList: PlaceholderPaddedList<T>?,
769         postList: PlaceholderPaddedList<T>
770     ) : RecyclerView.AdapterDataObserver() {
771         private val callback = ValidatingListUpdateCallback(previousList, postList)
772 
773         override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
774             callback.onChanged(positionStart, itemCount, null)
775         }
776 
777         override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
778             callback.onChanged(positionStart, itemCount, payload)
779         }
780 
781         override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
782             callback.onInserted(positionStart, itemCount)
783         }
784 
785         override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
786             callback.onRemoved(positionStart, itemCount)
787         }
788 
789         fun validateRunningListAgainst() {
790             callback.validateRunningListAgainst()
791         }
792     }
793 
794     companion object {
795         private const val RV_HEIGHT = 100
796         private const val ITEM_HEIGHT = 10
797         private const val RANDOM_TEST_REPEAT_SIZE = 1_000
798     }
799 }
800 
getnull801 private fun <T> PlaceholderPaddedList<T>.get(index: Int): T? {
802     if (index < placeholdersBefore) return null
803     val storageIndex = index - placeholdersBefore
804     if (storageIndex >= dataCount) return null
805     return getItem(storageIndex)
806 }
807 
808 /** Create a snapshot of this current that can be used to verify diffs. */
createSnapshotnull809 private fun <T> PlaceholderPaddedList<T>.createSnapshot(): MutableList<ListSnapshotItem> =
810     (0 until size).mapTo(mutableListOf()) { pos ->
811         get(pos)?.let { ListSnapshotItem.Item(it) } ?: ListSnapshotItem.Placeholder(pos)
812     }
813 
814 /** Sealed classes to identify items in the list. */
815 internal sealed class ListSnapshotItem {
816     // means the item didn't change at all in diffs.
isOriginalItemnull817     fun isOriginalItem() = this is Item<*> || this is Placeholder
818 
819     /** Asserts that this item properly represents the replacement (newListItem). */
820     abstract fun assertReplacement(msg: String, newListItem: ListSnapshotItem)
821 
822     data class Item<T>(val item: T) : ListSnapshotItem() {
823         override fun assertReplacement(msg: String, newListItem: ListSnapshotItem) {
824             // no change
825             assertWithMessage(msg).that(newListItem).isEqualTo(this)
826         }
827     }
828 
829     data class Placeholder(val pos: Int) : ListSnapshotItem() {
assertReplacementnull830         override fun assertReplacement(msg: String, newListItem: ListSnapshotItem) {
831             assertWithMessage(msg).that(newListItem).isInstanceOf(Placeholder::class.java)
832             val replacement = newListItem as Placeholder
833             // make sure position didn't change. If it did, we would be replaced with a [Changed].
834             assertWithMessage(msg).that(pos).isEqualTo(replacement.pos)
835         }
836     }
837 
838     /** Inserted into the list when we receive a change notification about an item/placeholder. */
839     data class Changed(val payload: DiffingChangePayload?) : ListSnapshotItem() {
assertReplacementnull840         override fun assertReplacement(msg: String, newListItem: ListSnapshotItem) {
841             // there are 4 cases for changes.
842             // is either placeholder -> placeholder with new position
843             // placeholder to item
844             // item to placeholder
845             // item change from original diffing.
846             when (payload) {
847                 DiffingChangePayload.ITEM_TO_PLACEHOLDER -> {
848                     assertWithMessage(msg).that(newListItem).isInstanceOf(Placeholder::class.java)
849                 }
850                 DiffingChangePayload.PLACEHOLDER_TO_ITEM -> {
851                     assertWithMessage(msg).that(newListItem).isInstanceOf(Item::class.java)
852                 }
853                 DiffingChangePayload.PLACEHOLDER_POSITION_CHANGE -> {
854                     assertWithMessage(msg).that(newListItem).isInstanceOf(Placeholder::class.java)
855                 }
856                 else -> {
857                     // item change that came from diffing.
858                     assertWithMessage(msg).that(newListItem).isInstanceOf(Item::class.java)
859                 }
860             }
861         }
862     }
863 
864     /** Used when an item/placeholder is inserted to the list */
865     object Inserted : ListSnapshotItem() {
assertReplacementnull866         override fun assertReplacement(msg: String, newListItem: ListSnapshotItem) {
867             // nothing to assert here, it can represent anything in the new list.
868         }
869     }
870 }
871