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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
package social.grain
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.feature.notifications.NotificationsViewModel
import social.grain.feature.notifications.UnseenNotificationCount
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.MainDispatcherRule
import social.grain.support.appContext
import social.grain.support.awaitCall
import social.grain.support.awaitValue
import social.grain.support.json
import social.grain.support.settle
/**
* The notifications list and the badge that rides on it.
*
* Grouping is tested on its own elsewhere; what matters here is that a second
* page is folded into the rows already on screen rather than regrouped from
* scratch — regrouping reorders rows under the reader's thumb — and that the
* badge is cleared optimistically but put back if the write fails.
*/
@RunWith(AndroidJUnit4::class)
class NotificationsViewModelTest {
@get:Rule
val mainDispatcher = MainDispatcherRule()
private lateinit var harness: Harness
private lateinit var unseen: UnseenNotificationCount
private val nsid = "social.grain.notification.listNotifications"
private fun page(vararg items: String, cursor: String? = null) =
"""{"notifications":[${items.joinToString(",")}]""" +
(cursor?.let { ""","cursor":"$it"""" } ?: "") + "}"
/** What the server counts as unread. */
private fun unread(count: Int) =
harness.server.on("social.grain.notification.getUnreadCount", """{"count":$count}""")
@Before
fun setUp() {
harness = Harness()
unseen = UnseenNotificationCount(appContext, harness.client, harness.auth)
harness.server.on(nsid, page(json(Fixtures.notification())))
unread(2)
}
@After
fun tearDown() = harness.tearDown()
private fun viewModel() = NotificationsViewModel(harness.client, harness.auth, unseen)
@Test
fun theFirstPageLoadsAndSetsTheBadge() {
val model = viewModel()
val state = model.state.awaitValue("the first page") { it.hasLoaded }
assertEquals(1, state.groups.size)
assertFalse(state.isLoading)
assertNull(state.errorMessage)
assertEquals(2, unseen.count.value)
}
@Test
fun favoritesOnTheSameGalleryCollapseIntoOneRow() {
harness.server.on(
nsid,
page(
json(Fixtures.notification(rkey = "n1")),
json(Fixtures.notification(rkey = "n2", author = Fixtures.profile(did = "did:plc:bob"))),
),
)
val model = viewModel()
val state = model.state.awaitValue("the first page") { it.hasLoaded }
assertEquals(1, state.groups.size)
assertEquals(2, state.groups.single().authorCount)
assertTrue(state.groups.single().isGrouped)
}
@Test
fun commentsStayOnTheirOwnRows() {
harness.server.on(
nsid,
page(
json(Fixtures.notification(rkey = "n1", reason = "gallery-comment", commentText = "One")),
json(Fixtures.notification(rkey = "n2", reason = "gallery-comment", commentText = "Two")),
),
)
val model = viewModel()
val state = model.state.awaitValue("the first page") { it.hasLoaded }
assertEquals(2, state.groups.size)
}
@Test
fun aFailedLoadReportsItsMessage() {
harness.server.on(nsid, """{"error":"nope"}""", code = 500)
val model = viewModel()
val state = model.state.awaitValue("the error") { it.errorMessage != null }
assertEquals("HTTP error 500", state.errorMessage)
assertTrue(state.hasLoaded)
model.dismissError()
assertNull(model.state.value.errorMessage)
}
@Test
fun asecondPageIsMergedIntoTheRowsAlreadyShowing() {
harness.server.on(nsid, page(json(Fixtures.notification(rkey = "n1")), cursor = "c1"))
val model = viewModel()
model.state.awaitValue("the first page") { it.hasLoaded }
harness.server.on(
nsid,
page(json(Fixtures.notification(rkey = "n2", author = Fixtures.profile(did = "did:plc:bob")))),
)
model.loadMore()
val state = model.state.awaitValue("the merge") { it.groups.firstOrNull()?.isGrouped == true }
assertEquals("Still one row, two faces", 1, state.groups.size)
assertEquals(2, state.groups.single().authorCount)
assertFalse(state.isLoadingMore)
}
@Test
fun aPageWithNoCursorIsTheEndOfTheList() {
val model = viewModel()
model.state.awaitValue("the first page") { it.hasLoaded }
val before = harness.server.countOf(nsid)
model.loadMore()
settle()
assertEquals(before, harness.server.countOf(nsid))
}
@Test
fun refreshingStartsTheListOver() {
val model = viewModel()
model.state.awaitValue("the first page") { it.hasLoaded }
harness.server.on(
nsid,
page(json(Fixtures.notification(rkey = "fresh", reason = "follow"))),
)
model.refresh()
model.state.awaitValue("the refreshed list") {
it.groups.singleOrNull()?.notification?.reason == "follow"
}
}
@Test
fun markingSeenClearsTheBadgeAndTellsTheServer() {
val model = viewModel()
model.state.awaitValue("the first page") { it.hasLoaded }
assertEquals(2, unseen.count.value)
model.markSeen()
assertEquals(0, unseen.count.value)
assertTrue(
harness.server.awaitCall("social.grain.notification.updateSeen")
.body.contains("seenAt"),
)
}
@Test
fun aFailedSeenWritePutsTheBadgeBack() {
harness.server.on("social.grain.notification.updateSeen", """{"error":"no"}""", code = 500)
val model = viewModel()
model.state.awaitValue("the first page") { it.hasLoaded }
model.markSeen()
val deadline = System.currentTimeMillis() + 5_000
while (unseen.count.value != 2 && System.currentTimeMillis() < deadline) Thread.sleep(10)
assertEquals(2, unseen.count.value)
}
@Test
fun anAlreadyEmptyBadgeIsStillTakenDownWithoutWritingToTheServer() {
unread(0)
val model = viewModel()
model.state.awaitValue("the first page") { it.hasLoaded }
model.markSeen()
settle()
assertEquals(0, unseen.count.value)
assertEquals(0, harness.server.countOf("social.grain.notification.updateSeen"))
}
@Test
fun theCheapPollUpdatesTheBadgeOnItsOwn() {
unread(7)
kotlinx.coroutines.runBlocking { unseen.refresh() }
assertEquals(7, unseen.count.value)
assertEquals(0, harness.server.countOf(nsid))
}
@Test
fun theBadgeNeverGoesNegative() {
unseen.set(-3)
assertEquals(0, unseen.count.value)
}
}