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