~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
297 lines · 10.2 KB Raw History
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.comment.getThread"

    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("com.atproto.repo.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("com.atproto.repo.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("com.atproto.repo.createRecord"))
    }

    @Test
    fun postingWithNoSheetOpenDoesNothing() {
        viewModel().postComment("Hello", replyTo = null)
        settle()
        assertEquals(0, harness.server.countOf("com.atproto.repo.createRecord"))
    }

    @Test
    fun aCommentThatFailsToPostSaysSo() {
        harness.server.on("com.atproto.repo.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("com.atproto.repo.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("com.atproto.repo.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("com.atproto.repo.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("com.atproto.repo.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)
    }
}