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.stories.StoryCommentsViewModel import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.awaitCall import social.grain.support.awaitValue import social.grain.support.json import social.grain.support.settle /** * Comments on whichever story is on screen. * * The viewer swipes between stories without leaving the screen, so the badge * has to be right for every one of them without paying for a full thread fetch * per swipe — which is what the preview cache is for, and what makes "paging * back to a story already seen" the interesting case. */ @RunWith(AndroidJUnit4::class) class StoryCommentsViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness private val nsid = "social.grain.unspecced.getCommentThread" private val first = "at://did:plc:alice/social.grain.story/one" private val second = "at://did:plc:alice/social.grain.story/two" private fun thread(vararg comments: String, total: Int? = null) = """{"comments":[${comments.joinToString(",")}]""" + (total?.let { ""","totalCount":$it""" } ?: "") + "}" @Before fun setUp() { harness = Harness() harness.server.on(nsid, thread(json(Fixtures.comment(text = "First one")), total = 4)) } @After fun tearDown() = harness.tearDown() private fun viewModel() = StoryCommentsViewModel(harness.client, harness.auth) @Test fun switchingToAStoryReadsItsPreviewAndCount() { val model = viewModel() model.switchToStory(first) val state = model.state.awaitValue("the preview") { it.firstComment != null } assertEquals("First one", state.firstComment!!.text) assertEquals(4, state.totalCount) assertEquals("The preview is one comment, not the thread", "1", harness.server.lastCall(nsid).params["limit"]) } @Test fun switchingToTheStoryAlreadyShowingCostsNothing() { val model = viewModel() model.switchToStory(first) model.state.awaitValue("the preview") { it.firstComment != null } model.switchToStory(first) settle() assertEquals(1, harness.server.countOf(nsid)) } @Test fun pagingBackToAStoryAlreadySeenRedrawsFromTheCache() { val model = viewModel() model.switchToStory(first) model.state.awaitValue("the first preview") { it.firstComment != null } harness.server.on(nsid, thread(json(Fixtures.comment(text = "Second one")), total = 1)) model.switchToStory(second) model.state.awaitValue("the second preview") { it.firstComment?.text == "Second one" } val fetches = harness.server.countOf(nsid) model.switchToStory(first) val back = model.state.awaitValue("the cached preview") { it.firstComment?.text == "First one" } assertEquals(4, back.totalCount) settle() assertEquals("No refetch for a story already seen", fetches, harness.server.countOf(nsid)) } @Test fun openingTheSheetReadsTheWholeThread() { val model = viewModel() model.switchToStory(first) model.state.awaitValue("the preview") { it.firstComment != null } harness.server.on( nsid, thread(json(Fixtures.comment()), json(Fixtures.comment(rkey = "c2", text = "Two")), total = 2), ) model.loadComments() val state = model.state.awaitValue("the thread") { it.hasLoaded } assertEquals(2, state.comments.size) assertEquals(2, state.totalCount) assertNull(harness.server.lastCall(nsid).params["limit"]?.takeIf { it == "1" }) } @Test fun loadingWithNoStoryOnScreenDoesNothing() { viewModel().loadComments() settle() assertEquals(0, harness.server.countOf(nsid)) } @Test fun aThreadThatFailsSaysSo() { val model = viewModel() model.switchToStory(first) model.state.awaitValue("the preview") { it.firstComment != null } harness.server.on(nsid, """{"error":"no"}""", code = 500) model.loadComments() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("Couldn't load comments.", state.errorMessage) assertTrue(state.hasLoaded) model.dismissError() assertNull(model.state.value.errorMessage) } @Test fun aFailedPreviewLeavesTheBadgeAloneWithoutRaisingAnError() { harness.server.on(nsid, """{"error":"no"}""", code = 500) val model = viewModel() model.switchToStory(first) harness.server.awaitCall(nsid) settle() assertNull(model.state.value.firstComment) assertNull(model.state.value.errorMessage) } @Test fun postingACommentInvalidatesTheCachedPreview() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/comment/new"}""") val model = viewModel() model.switchToStory(first) model.state.awaitValue("the preview") { it.firstComment != null } harness.server.on( nsid, thread(json(Fixtures.comment(text = "Mine")), json(Fixtures.comment(rkey = "c2")), total = 5), ) model.postComment(" Mine ", replyTo = null) val state = model.state.awaitValue("the reread thread") { it.comments.size == 2 } assertEquals(5, state.totalCount) assertFalse(state.isPosting) assertTrue(harness.server.awaitCall("dev.hatk.createRecord").body.contains("\"text\":\"Mine\"")) } @Test fun anEmptyCommentIsNotPostedAndNeitherIsOneWithNoStory() { val model = viewModel() model.postComment("Hello", replyTo = null) model.switchToStory(first) model.state.awaitValue("the preview") { it.firstComment != null } model.postComment(" ", replyTo = null) settle() assertEquals(0, harness.server.countOf("dev.hatk.createRecord")) } @Test fun aCommentThatFailsToPostSaysSo() { harness.server.on("dev.hatk.createRecord", """{"error":"no"}""", code = 500) val model = viewModel() model.switchToStory(first) model.state.awaitValue("the preview") { it.firstComment != null } model.postComment("Mine", replyTo = null) val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("Couldn't post that comment.", state.errorMessage) assertFalse(state.isPosting) } @Test fun deletingACommentTakesItsRepliesAndOneOffTheCount() { val root = Fixtures.comment(rkey = "root") harness.server.on( nsid, thread(json(root), json(Fixtures.comment(rkey = "reply", replyTo = root.uri)), total = 2), ) val model = viewModel() model.switchToStory(first) model.loadComments() val loaded = model.state.awaitValue("the thread") { it.comments.size == 2 } model.deleteComment(loaded.comments.first()) val state = model.state.awaitValue("the rows to go") { it.comments.isEmpty() } assertEquals(1, state.totalCount) } @Test fun aDeleteThatFailsPutsTheRowsBack() { harness.server.on("dev.hatk.deleteRecord", """{"error":"no"}""", code = 500) val model = viewModel() model.switchToStory(first) model.loadComments() val loaded = model.state.awaitValue("the thread") { it.hasLoaded } model.deleteComment(loaded.comments.first()) val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals(1, state.comments.size) } @Test fun favoritingACommentFlipsItThenKeepsTheRecordUri() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/1"}""") val model = viewModel() model.switchToStory(first) model.loadComments() val comment = model.state.awaitValue("the thread") { it.hasLoaded }.comments.single() model.toggleFavorite(comment) assertEquals(1, model.state.value.comments.single().favCount) model.state.awaitValue("the record uri") { it.comments.single().viewer?.fav == "at://did:plc:me/fav/1" } } @Test fun aCommentFavoriteThatFailsIsPutBack() { harness.server.on("dev.hatk.createRecord", """{"error":"no"}""", code = 500) val model = viewModel() model.switchToStory(first) model.loadComments() val comment = model.state.awaitValue("the thread") { it.hasLoaded }.comments.single() model.toggleFavorite(comment) val state = model.state.awaitValue("the revert") { it.comments.single().viewer?.fav == null } assertEquals(0, state.comments.single().favCount) } @Test fun unfavoritingACommentDeletesTheRecord() { harness.server.on( nsid, thread(json(Fixtures.comment(favUri = "at://did:plc:me/social.grain.favorite/cf1"))), ) val model = viewModel() model.switchToStory(first) model.loadComments() val comment = model.state.awaitValue("the thread") { it.hasLoaded }.comments.single() model.toggleFavorite(comment) assertTrue(harness.server.awaitCall("dev.hatk.deleteRecord").body.contains("cf1")) } @Test fun repliesAreGroupedUnderTheirRoot() { val root = Fixtures.comment(rkey = "root", text = "Root") harness.server.on( nsid, thread(json(root), json(Fixtures.comment(rkey = "r", text = "Reply", replyTo = root.uri))), ) val model = viewModel() model.switchToStory(first) model.loadComments() val state = model.state.awaitValue("the thread") { it.comments.size == 2 } assertEquals(1, state.threads.size) assertEquals("Reply", state.threads.single().replies.single().text) } }