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.unspecced.getNotifications" private fun page(vararg items: String, cursor: String? = null, unseenCount: Int? = null) = """{"notifications":[${items.joinToString(",")}]""" + (cursor?.let { ""","cursor":"$it"""" } ?: "") + (unseenCount?.let { ""","unseenCount":$it""" } ?: "") + "}" @Before fun setUp() { harness = Harness() unseen = UnseenNotificationCount(appContext, harness.client, harness.auth) harness.server.on(nsid, page(json(Fixtures.notification()), unseenCount = 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("dev.hatk.putPreference") .body.contains("lastSeenNotifications"), ) } @Test fun aFailedSeenWritePutsTheBadgeBack() { harness.server.on("dev.hatk.putPreference", """{"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() { harness.server.on(nsid, page(json(Fixtures.notification()), unseenCount = 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("dev.hatk.putPreference")) } @Test fun theCheapPollUpdatesTheBadgeOnItsOwn() { harness.server.on(nsid, page(unseenCount = 7)) kotlinx.coroutines.runBlocking { unseen.refresh() } assertEquals(7, unseen.count.value) assertEquals("true", harness.server.lastCall(nsid).params["countOnly"]) } @Test fun theBadgeNeverGoesNegative() { unseen.set(-3) assertEquals(0, unseen.count.value) } }