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

grain.social/grain-android

1 collaborator
0
1
main
291 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.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.comment.getThread"
    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("com.atproto.repo.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("com.atproto.repo.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("com.atproto.repo.createRecord"))
    }

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