-
Notifications
You must be signed in to change notification settings - Fork 28
/
BitmapCacheTest.kt
203 lines (168 loc) · 7.21 KB
/
BitmapCacheTest.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
package me.saket.telephoto.subsamplingimage.internal
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.colorspace.ColorSpace
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.job
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class BitmapCacheTest {
private val decoder = FakeImageRegionDecoder()
private fun TestScope.bitmapCache(
throttleEvery: Duration = 100.milliseconds
) = BitmapCache(
scope = backgroundScope,
decoder = decoder,
throttleEvery = throttleEvery,
)
@Test fun `when tiles are received, load bitmaps only for new tiles`() = runTest(timeout = 1.seconds) {
turbineScope {
val cache = bitmapCache(2.seconds)
val requestedRegions = decoder.requestedRegions.testIn(this)
val cachedBitmaps = cache.cachedBitmaps().testIn(this)
assertThat(cachedBitmaps.awaitItem()).isEmpty() // Default item.
val tile1 = fakeBitmapRegionTile(4)
val tile2 = fakeBitmapRegionTile(4)
cache.loadOrUnloadForTiles(listOf(tile1, tile2))
decoder.decodedBitmaps.send(FakeImageBitmap())
decoder.decodedBitmaps.send(FakeImageBitmap())
assertThat(requestedRegions.awaitItem()).isEqualTo(tile1)
assertThat(requestedRegions.awaitItem()).isEqualTo(tile2)
cachedBitmaps.skipItems(1)
assertThat(cachedBitmaps.awaitItem().keys).containsExactly(tile1, tile2)
val tile3 = fakeBitmapRegionTile(4)
cache.loadOrUnloadForTiles(listOf(tile1, tile2, tile3))
decoder.decodedBitmaps.send(FakeImageBitmap())
assertThat(requestedRegions.awaitItem()).isEqualTo(tile3)
assertThat(cachedBitmaps.awaitItem().keys).containsExactly(tile1, tile2, tile3)
requestedRegions.cancelAndExpectNoEvents()
cachedBitmaps.cancelAndExpectNoEvents()
}
}
@Test fun `when tiles are removed, discard their stale bitmaps from cache`() = runTest(timeout = 1.seconds) {
val cache = bitmapCache(2.seconds)
cache.cachedBitmaps().drop(1).test {
val tile1 = fakeBitmapRegionTile(4)
val tile2 = fakeBitmapRegionTile(4)
cache.loadOrUnloadForTiles(listOf(tile1, tile2))
decoder.decodedBitmaps.send(FakeImageBitmap())
decoder.decodedBitmaps.send(FakeImageBitmap())
skipItems(1)
assertThat(awaitItem().keys).containsExactly(tile1, tile2)
val tile3 = fakeBitmapRegionTile(4)
cache.loadOrUnloadForTiles(listOf(tile3))
decoder.decodedBitmaps.send(FakeImageBitmap())
skipItems(1)
assertThat(awaitItem().keys).containsExactly(tile3)
cancelAndExpectNoEvents()
}
}
@Test fun `when a tile is removed before its bitmap could be loaded, cancel its in-flight load`() =
runTest(timeout = 1.seconds) {
turbineScope {
val cache = bitmapCache(2.seconds)
val requestedRegions = decoder.requestedRegions.testIn(this)
val cachedBitmaps = cache.cachedBitmaps().drop(1).testIn(this)
val visibleTile = fakeBitmapRegionTile(4)
cache.loadOrUnloadForTiles(listOf(visibleTile))
assertThat(requestedRegions.awaitItem()).isEqualTo(visibleTile)
cachedBitmaps.expectNoEvents()
cache.loadOrUnloadForTiles(emptyList())
requestedRegions.cancelAndExpectNoEvents()
cachedBitmaps.cancelAndExpectNoEvents()
// Verify that BitmapCache has cancelled all loading jobs.
// I don't think it's possible to uniquely identify BitmapCache's loading jobs.
// Checking that there aren't any active jobs should be sufficient for now.
assertThat(coroutineContext.job.children.none { it.isActive }).isTrue()
}
}
// Note to self: I'm using runBlocking() instead of runTest() here so that I can test delays.
// and also to work around https://github.com/cashapp/paparazzi/issues/1101.
@Test fun `throttle load requests`() = runBlocking {
val scope: CoroutineScope = this.plus(Job())
val cache = BitmapCache(
scope = scope,
decoder = decoder,
throttleEvery = 2.seconds,
)
decoder.requestedRegions.test {
val baseTile = fakeBitmapRegionTile(sampleSize = 4)
val tile2 = fakeBitmapRegionTile(sampleSize = 1)
val tile3 = fakeBitmapRegionTile(sampleSize = 1)
val tileToSkip = fakeBitmapRegionTile(sampleSize = 8)
cache.loadOrUnloadForTiles(listOf(baseTile))
// This tile should get overridden by the next set of
// tiles because the throttle window hasn't passed yet.
delay(500.milliseconds)
cache.loadOrUnloadForTiles(listOf(tileToSkip))
delay(500.milliseconds)
cache.loadOrUnloadForTiles(listOf(baseTile, tile2, tile3))
// If the same tiles are requested again within the throttle window,
// neither the old one nor the new one should get ignored for some reason.
cache.loadOrUnloadForTiles(listOf(baseTile, tile2, tile3))
assertThat(awaitItem()).isEqualTo(baseTile)
assertThat(listOf(awaitItem(), awaitItem())).containsExactly(tile2, tile3)
scope.cancel()
}
}
private fun fakeBitmapRegionTile(
sampleSize: Int = Random.nextInt(from = 0, until = 10) * 2,
): BitmapRegionTile {
val random = Random(seed = System.nanoTime())
return BitmapRegionTile(
sampleSize = BitmapSampleSize(sampleSize),
bounds = IntRect(random.nextInt(), random.nextInt(), random.nextInt(), random.nextInt())
)
}
}
private class FakeImageRegionDecoder : ImageRegionDecoder {
override val imageSize: IntSize get() = error("unused")
override val imageOrientation: ExifMetadata.ImageOrientation get() = error("unused")
val requestedRegions = MutableSharedFlow<BitmapRegionTile>()
val decodedBitmaps = Channel<ImageBitmap>()
override suspend fun decodeRegion(region: BitmapRegionTile): ImageBitmap {
requestedRegions.emit(region)
return decodedBitmaps.receive()
}
override fun recycle() = Unit
}
private class FakeImageBitmap : ImageBitmap {
override val colorSpace: ColorSpace get() = error("unused")
override val config: ImageBitmapConfig get() = error("unused")
override val hasAlpha: Boolean get() = error("unused")
override val height: Int get() = error("unused")
override val width: Int get() = error("unused")
override fun prepareToDraw() = Unit
override fun readPixels(
buffer: IntArray,
startX: Int,
startY: Int,
width: Int,
height: Int,
bufferOffset: Int,
stride: Int
) = Unit
}
private suspend fun <T> ReceiveTurbine<T>.cancelAndExpectNoEvents() {
expectNoEvents()
assertThat(cancelAndConsumeRemainingEvents()).isEmpty()
}