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.gallery.GalleryCommentsViewModel 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 import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** * The comment sheet opened over a feed card. * * One instance is reused as the reader opens the sheet on one card after * another, so the subject changing underneath an in-flight request is the * normal case here rather than an edge one — a late reply landing under the * wrong gallery is the failure this class is shaped to avoid. */ @RunWith(AndroidJUnit4::class) class GalleryCommentsViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness private val first = "at://did:plc:alice/social.grain.gallery/one" private val second = "at://did:plc:alice/social.grain.gallery/two" private val nsid = "social.grain.unspecced.getCommentThread" private fun thread(vararg comments: String, total: Int? = null, cursor: String? = null) = """{"comments":[${comments.joinToString(",")}]""" + (total?.let { ""","totalCount":$it""" } ?: "") + (cursor?.let { ""","cursor":"$it"""" } ?: "") + "}" @Before fun setUp() { harness = Harness() harness.server.on(nsid, thread(json(Fixtures.comment()), total = 3)) } @After fun tearDown() = harness.tearDown() private fun viewModel() = GalleryCommentsViewModel(harness.client, harness.auth) @Test fun openingASheetReadsThatGallerysThread() { val model = viewModel() model.open(first) val state = model.state.awaitValue("the thread") { it.hasLoaded } assertEquals(first, state.galleryUri) assertEquals(1, state.comments.size) assertEquals(3, state.totalCount) assertEquals(harness.did, state.viewerDid) } @Test fun aThreadWithNoTotalCountFallsBackToWhatItActuallyGotBack() { harness.server.on(nsid, thread(json(Fixtures.comment()), json(Fixtures.comment(rkey = "c2")))) val model = viewModel() model.open(first) assertEquals(2, model.state.awaitValue("the thread") { it.hasLoaded }.totalCount) } @Test fun openingADifferentGalleryClearsWhatTheLastOneShowed() { val model = viewModel() model.open(first) model.state.awaitValue("the first thread") { it.hasLoaded } // Held open so the clearing can be observed before the answer lands — // the point of it is what the reader sees in between. val answer = CountDownLatch(1) harness.server.on(nsid) { answer.await(5, TimeUnit.SECONDS) thread(json(Fixtures.comment(rkey = "other", text = "Elsewhere"))) } model.open(second) assertTrue("The previous gallery's rows go immediately", model.state.value.comments.isEmpty()) assertFalse(model.state.value.hasLoaded) answer.countDown() val state = model.state.awaitValue("the second thread") { it.hasLoaded } assertEquals("Elsewhere", state.comments.single().text) } @Test fun reopeningTheSameGalleryKeepsTheRowsWhileItRefetches() { val model = viewModel() model.open(first) model.state.awaitValue("the thread") { it.hasLoaded } model.open(first) assertTrue("No spinner over rows we already have", model.state.value.hasLoaded) assertEquals(1, model.state.value.comments.size) } @Test fun aThreadThatFailsToLoadSaysSo() { harness.server.on(nsid, """{"error":"nope"}""", code = 500) val model = viewModel() model.open(first) val state = model.state.awaitValue("the error") { it.errorMessage != null } assertTrue(state.hasLoaded) assertEquals("Couldn't load comments.", state.errorMessage) model.dismissError() assertNull(model.state.value.errorMessage) } @Test fun moreCommentsAppendBehindTheCursor() { harness.server.on(nsid, thread(json(Fixtures.comment()), cursor = "c1")) val model = viewModel() model.open(first) model.state.awaitValue("the thread") { it.hasLoaded } harness.server.on(nsid, thread(json(Fixtures.comment(rkey = "c2", text = "Second")))) model.loadMore() val state = model.state.awaitValue("the second page") { it.comments.size == 2 } assertEquals("Second", state.comments[1].text) assertFalse(state.isLoadingMore) } @Test fun loadingMoreBeforeASheetIsOpenDoesNothing() { viewModel().loadMore() settle() assertEquals(0, harness.server.countOf(nsid)) } @Test fun postingACommentReReadsTheThread() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/comment/new"}""") val model = viewModel() model.open(first) model.state.awaitValue("the thread") { it.hasLoaded } harness.server.on( nsid, thread(json(Fixtures.comment()), json(Fixtures.comment(rkey = "c2", text = "Mine")), total = 4), ) model.postComment(" Mine ", replyTo = null) val state = model.state.awaitValue("the reread thread") { it.comments.size == 2 } assertEquals(4, state.totalCount) assertFalse(state.isPosting) assertTrue(harness.server.awaitCall("dev.hatk.createRecord").body.contains("\"text\":\"Mine\"")) } @Test fun anEmptyCommentIsNotPosted() { val model = viewModel() model.open(first) model.state.awaitValue("the thread") { it.hasLoaded } model.postComment(" ", replyTo = null) settle() assertEquals(0, harness.server.countOf("dev.hatk.createRecord")) } @Test fun postingWithNoSheetOpenDoesNothing() { viewModel().postComment("Hello", 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.open(first) model.state.awaitValue("the thread") { it.hasLoaded } 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 deletingACommentTakesItsRepliesAndTheCountWithIt() { 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.open(first) 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(0, state.totalCount) } @Test fun aDeleteThatFailsPutsTheRowsBack() { harness.server.on("dev.hatk.deleteRecord", """{"error":"no"}""", code = 500) val model = viewModel() model.open(first) val loaded = model.state.awaitValue("the thread") { it.hasLoaded } model.deleteComment(loaded.comments.single()) val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals(1, state.comments.size) assertEquals(3, state.totalCount) } @Test fun favoritingACommentFlipsItThenKeepsTheRecordUri() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/1"}""") val model = viewModel() model.open(first) 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.open(first) 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.open(first) 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.open(first) val state = model.state.awaitValue("the thread") { it.comments.size == 2 } assertEquals(1, state.threads.size) assertEquals("Reply", state.threads.single().replies.single().text) } }