Skip to content

Commit e626f23

Browse files
committed
test(chat): cover the emoji picker dropping a search typed past
EmojiPickerViewModelTest holds each section load open, finishes the "dog" search before the earlier "cat" one, and checks the late "cat" results don't replace "dog". Swapping mapLatest for flatMapMerge makes it fail with [cat face]. Robolectric's Paint reports no emoji glyphs, which empties every section, so the view model takes a GlyphProbe through an internal constructor. The @Inject constructor still passes PaintGlyphProbe.
1 parent 75976cc commit e626f23

2 files changed

Lines changed: 120 additions & 2 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/EmojiPickerViewModel.kt‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,24 @@ import kotlinx.coroutines.flow.onEach
2525
* it opens. Choosing an emoji is a chat action and stays on `ChatViewModel.Event.ToggleReaction`.
2626
*/
2727
@HiltViewModel
28-
class EmojiPickerViewModel @Inject constructor(
28+
class EmojiPickerViewModel internal constructor(
2929
dispatchers: DispatcherProvider,
3030
private val emojiCatalogLoader: EmojiCatalogLoader,
3131
private val recentReactionsStore: RecentReactionsStore,
32+
// A seam for tests: Robolectric's Paint reports no emoji glyphs, which would empty every section.
33+
private val glyphProbe: EmojiDrawability.GlyphProbe,
3234
) : BaseViewModel<EmojiPickerViewModel.State, EmojiPickerViewModel.Event>(
3335
initialState = State(),
3436
updateStateForEvent = updateStateForEvent,
3537
defaultDispatcher = dispatchers.Default,
3638
) {
39+
@Inject
40+
constructor(
41+
dispatchers: DispatcherProvider,
42+
emojiCatalogLoader: EmojiCatalogLoader,
43+
recentReactionsStore: RecentReactionsStore,
44+
) : this(dispatchers, emojiCatalogLoader, recentReactionsStore, EmojiDrawability.PaintGlyphProbe)
45+
3746
data class State(
3847
val searchFieldState: TextFieldState = TextFieldState(),
3948
/** The sections for the search text; stale for a moment while a new search's load is in flight. */
@@ -67,7 +76,7 @@ class EmojiPickerViewModel @Inject constructor(
6776
val catalog = emojiCatalogLoader.load()
6877
val undrawable = catalog.entries
6978
.map { it.emoji }
70-
.filterNot { EmojiDrawability.isDrawable(it) }
79+
.filterNot { EmojiDrawability.isDrawable(it, glyphProbe) }
7180
.toSet()
7281
val recents = recentReactionsStore.rank(undrawable = undrawable)
7382
return EmojiPickerModel.sections(catalog = catalog, undrawable = undrawable, recents = recents, query = query)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.flipcash.app.messenger.internal.screens
2+
3+
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
4+
import androidx.compose.runtime.snapshots.Snapshot
5+
import com.flipcash.libs.coroutines.TestDispatcherProvider
6+
import com.getcode.libs.emojis.reactions.EmojiCatalogLoader
7+
import com.getcode.libs.emojis.reactions.EmojiPickerModel
8+
import com.getcode.libs.emojis.reactions.RecentReactions
9+
import com.getcode.libs.emojis.reactions.RecentReactionsStore
10+
import kotlinx.coroutines.CompletableDeferred
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.ExperimentalCoroutinesApi
13+
import kotlinx.coroutines.test.TestCoroutineScheduler
14+
import kotlinx.coroutines.test.TestScope
15+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
16+
import kotlinx.coroutines.test.advanceUntilIdle
17+
import kotlinx.coroutines.test.resetMain
18+
import kotlinx.coroutines.test.runTest
19+
import kotlinx.coroutines.test.setMain
20+
import org.junit.After
21+
import org.junit.Before
22+
import org.junit.Test
23+
import org.junit.runner.RunWith
24+
import org.robolectric.RobolectricTestRunner
25+
import org.robolectric.annotation.Config
26+
import kotlin.test.assertEquals
27+
28+
/**
29+
* A search's sections load off the main thread, so a slow load for text the reader has since
30+
* typed past can finish after the load for what they typed next. The picker must keep showing the
31+
* newer search's results rather than let the late one overwrite them.
32+
*/
33+
@OptIn(ExperimentalCoroutinesApi::class)
34+
@RunWith(RobolectricTestRunner::class)
35+
@Config(sdk = [34])
36+
class EmojiPickerViewModelTest {
37+
38+
private val scheduler = TestCoroutineScheduler()
39+
private val dispatcher = UnconfinedTestDispatcher(scheduler)
40+
41+
private val catalog = EmojiCatalogLoader(dispatcher = dispatcher) {
42+
"""
43+
{
44+
"categories": ["Animals"],
45+
"emoji": [
46+
{"emoji": "🐱", "name": "cat face", "category": "Animals", "version": "1.0", "skinTone": false},
47+
{"emoji": "🐶", "name": "dog face", "category": "Animals", "version": "1.0", "skinTone": false}
48+
]
49+
}
50+
""".trimIndent()
51+
}
52+
53+
/** Holds each section load at its recents read until the test releases it, in call order. */
54+
private val recents = object : RecentReactionsStore {
55+
val pending = mutableListOf<CompletableDeferred<Unit>>()
56+
57+
override suspend fun record(emoji: String) = Unit
58+
override suspend fun stats(): Map<String, RecentReactions.Usage> = emptyMap()
59+
override suspend fun rank(undrawable: Set<String>, limit: Int): List<String> {
60+
CompletableDeferred<Unit>().also { pending += it }.await()
61+
return emptyList()
62+
}
63+
}
64+
65+
@Before
66+
fun setUp() {
67+
Dispatchers.setMain(dispatcher)
68+
}
69+
70+
@After
71+
fun tearDown() {
72+
Dispatchers.resetMain()
73+
}
74+
75+
private fun TestScope.search(model: EmojiPickerViewModel, text: String) {
76+
model.stateFlow.value.searchFieldState.setTextAndPlaceCursorAtEnd(text)
77+
Snapshot.sendApplyNotifications()
78+
advanceUntilIdle()
79+
}
80+
81+
private fun EmojiPickerViewModel.resultNames(): List<String> =
82+
stateFlow.value.sections.single { it.id == EmojiPickerModel.SEARCH_RESULTS_ID }.entries.map { it.name }
83+
84+
@Test
85+
fun `a search typed past that finishes late does not replace the newer results`() = runTest(scheduler) {
86+
val model = EmojiPickerViewModel(
87+
dispatchers = TestDispatcherProvider(dispatcher),
88+
emojiCatalogLoader = catalog,
89+
recentReactionsStore = recents,
90+
glyphProbe = { true },
91+
)
92+
advanceUntilIdle()
93+
recents.pending[0].complete(Unit) // the opening, unsearched load
94+
advanceUntilIdle()
95+
96+
search(model, "cat")
97+
val cat = recents.pending[1]
98+
search(model, "dog")
99+
val dog = recents.pending[2]
100+
101+
dog.complete(Unit)
102+
advanceUntilIdle()
103+
assertEquals(listOf("dog face"), model.resultNames())
104+
105+
cat.complete(Unit)
106+
advanceUntilIdle()
107+
assertEquals(listOf("dog face"), model.resultNames())
108+
}
109+
}

0 commit comments

Comments
 (0)