Skip to content

Commit 61d24d9

Browse files
test(conversations): cover the foreground refresh loop
The loop fires whatever else is going on, so what decides whether the list stays usable is when a tick is dropped: one that is not dropped while a sync is running queues requests behind each other, and one that is not dropped during a search swaps the list out from under what the user is reading. The two cases pin each other down. A tick during an in-flight sync must leave the call count at one, and a tick after the previous sync finished must take it to two, so neither dropping every tick nor dropping none of them passes both. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent cc17f17 commit 61d24d9

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Andy Scherzinger <info@andy-scherzinger.de>
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.conversationlist.viewmodels
9+
10+
import android.app.Application
11+
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
12+
import com.nextcloud.talk.contacts.ContactsRepository
13+
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
14+
import com.nextcloud.talk.conversationlist.data.network.ConversationListUpdater
15+
import com.nextcloud.talk.data.user.model.User
16+
import com.nextcloud.talk.invitation.data.InvitationsRepository
17+
import com.nextcloud.talk.logger.Logger
18+
import com.nextcloud.talk.openconversations.data.OpenConversationsRepository
19+
import com.nextcloud.talk.repositories.conversations.ConversationsRepository
20+
import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository
21+
import com.nextcloud.talk.threadsoverview.data.ThreadsRepository
22+
import com.nextcloud.talk.users.UserManager
23+
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
24+
import io.reactivex.Maybe
25+
import kotlinx.coroutines.CompletableJob
26+
import kotlinx.coroutines.Dispatchers
27+
import kotlinx.coroutines.ExperimentalCoroutinesApi
28+
import kotlinx.coroutines.Job
29+
import kotlinx.coroutines.runBlocking
30+
import kotlinx.coroutines.flow.emptyFlow
31+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
32+
import kotlinx.coroutines.test.resetMain
33+
import kotlinx.coroutines.test.setMain
34+
import org.junit.After
35+
import org.junit.Before
36+
import org.junit.Test
37+
import org.junit.runner.RunWith
38+
import org.mockito.kotlin.any
39+
import org.mockito.kotlin.eq
40+
import org.mockito.kotlin.mock
41+
import org.mockito.kotlin.never
42+
import org.mockito.kotlin.times
43+
import org.mockito.kotlin.verify
44+
import org.mockito.kotlin.whenever
45+
import org.mockito.kotlin.wheneverBlocking
46+
import org.robolectric.RobolectricTestRunner
47+
import org.robolectric.annotation.Config
48+
49+
/**
50+
* Tests for [ConversationsListViewModel.refreshRoomsIfIdle]: which refresh ticks reach the
51+
* repository and which are dropped.
52+
*/
53+
@OptIn(ExperimentalCoroutinesApi::class)
54+
@RunWith(RobolectricTestRunner::class)
55+
@Config(application = Application::class, sdk = [33])
56+
class ConversationsListViewModelForegroundRefreshTest {
57+
58+
private val repository: OfflineConversationsRepository = mock()
59+
private val currentUserProvider: CurrentUserProviderOld = mock()
60+
61+
private lateinit var viewModel: ConversationsListViewModel
62+
63+
@Before
64+
fun setUp() {
65+
Dispatchers.setMain(UnconfinedTestDispatcher())
66+
wheneverBlocking { repository.isPeriodicSyncDue(any()) }.thenReturn(true)
67+
whenever(repository.roomListFlow).thenReturn(emptyFlow())
68+
whenever(repository.syncErrorFlow).thenReturn(emptyFlow())
69+
whenever(currentUserProvider.currentUser).thenReturn(Maybe.just(USER))
70+
71+
viewModel = ConversationsListViewModel(
72+
repository,
73+
mock<ThreadsRepository>(),
74+
currentUserProvider,
75+
mock<OpenConversationsRepository>(),
76+
mock<ContactsRepository>(),
77+
mock<UnifiedSearchRepository>(),
78+
mock<InvitationsRepository>(),
79+
mock<ArbitraryStorageManager>(),
80+
mock<UserManager>(),
81+
mock<ConversationsRepository>(),
82+
mock<ConversationListUpdater>(),
83+
mock<Logger>()
84+
)
85+
}
86+
87+
@After
88+
fun tearDown() {
89+
Dispatchers.resetMain()
90+
}
91+
92+
@Test
93+
fun `a tick refreshes the list once the previous sync has finished`() {
94+
whenever(repository.getRooms(any(), any())).thenReturn(completedJob())
95+
viewModel.getRooms(USER)
96+
97+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
98+
99+
verify(repository, times(2)).getRooms(eq(USER), eq(false))
100+
}
101+
102+
@Test
103+
fun `a tick while a sync is still running is dropped rather than queued`() {
104+
whenever(repository.getRooms(any(), any())).thenReturn(Job())
105+
viewModel.getRooms(USER)
106+
107+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
108+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
109+
110+
verify(repository, times(1)).getRooms(eq(USER), eq(false))
111+
}
112+
113+
@Test
114+
fun `a tick during an open search leaves the list alone`() {
115+
whenever(repository.getRooms(any(), any())).thenReturn(completedJob())
116+
viewModel.getRooms(USER)
117+
viewModel.setIsSearchActive(true)
118+
119+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
120+
121+
verify(repository, times(1)).getRooms(eq(USER), eq(false))
122+
}
123+
124+
@Test
125+
fun `the refresh resumes once the search is closed`() {
126+
whenever(repository.getRooms(any(), any())).thenReturn(completedJob())
127+
viewModel.getRooms(USER)
128+
viewModel.setIsSearchActive(true)
129+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
130+
viewModel.setIsSearchActive(false)
131+
132+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
133+
134+
verify(repository, times(2)).getRooms(eq(USER), eq(false))
135+
}
136+
137+
@Test
138+
fun `a tick that would have to fetch the whole list is dropped until the cadence is due`() {
139+
whenever(repository.getRooms(any(), any())).thenReturn(completedJob())
140+
wheneverBlocking { repository.isPeriodicSyncDue(any()) }.thenReturn(false)
141+
viewModel.getRooms(USER)
142+
143+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
144+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
145+
146+
verify(repository, times(1)).getRooms(eq(USER), eq(false))
147+
}
148+
149+
@Test
150+
fun `a tick never asks for a full sync, that is what the cadence and pull to refresh are for`() {
151+
whenever(repository.getRooms(any(), any())).thenReturn(completedJob())
152+
viewModel.getRooms(USER)
153+
154+
runBlocking { viewModel.refreshRoomsIfIdle(USER) }
155+
156+
verify(repository, never()).getRooms(any(), eq(true))
157+
}
158+
159+
private fun completedJob(): CompletableJob = Job().apply { complete() }
160+
161+
companion object {
162+
private val USER = User(
163+
id = 1,
164+
userId = "me",
165+
username = "me",
166+
token = "app-password",
167+
baseUrl = "https://server.example.com"
168+
)
169+
}
170+
}

0 commit comments

Comments
 (0)