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

grain.social/grain-android

1 collaborator
0
1
main
278 lines · 9.7 KB Raw History
package social.grain

import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.OkHttpClient
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.data.location.LocationServices
import social.grain.feature.feed.CameraFeedViewModel
import social.grain.feature.feed.HashtagFeedViewModel
import social.grain.feature.feed.LocationFeedViewModel
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 social.grain.support.testJson

/**
 * The feeds reached by tapping something on a card — a hashtag, a place name.
 *
 * They share a base class, so most of this exercises that: paging, optimistic
 * favourites, deletes. What the two subclasses add is one line each — which
 * `feed` parameter goes out — and that line is exactly what a screen test
 * cannot see.
 */
@RunWith(AndroidJUnit4::class)
class GalleryFeedViewModelTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    private lateinit var harness: Harness

    @Before
    fun setUp() {
        harness = Harness()
        harness.server
            .on("social.grain.feed.getFeed", """{"items":[${json(Fixtures.gallery())}]}""")
    }

    @After
    fun tearDown() = harness.tearDown()

    private fun hashtag(tag: String = "fog") = HashtagFeedViewModel(
        client = harness.client,
        auth = harness.auth,
        labelDefsCache = harness.labelDefinitions,
        savedStateHandle = SavedStateHandle(mapOf("tag" to tag)),
    )

    private fun location(cell: String = "8928308280fffff", name: String = "Mission District") =
        LocationFeedViewModel(
            client = harness.client,
            auth = harness.auth,
            locations = LocationServices(OkHttpClient(), testJson),
            labelDefsCache = harness.labelDefinitions,
            savedStateHandle = SavedStateHandle(mapOf("cell" to cell, "name" to name)),
        )

    @Test
    fun aHashtagFeedAsksForThatTag() {
        val model = hashtag("fog")
        val state = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        assertEquals("fog", model.tag)
        assertFalse(state.isLoading)
        assertFalse(state.isEmpty)
        assertEquals(harness.did, state.viewerDid)

        val call = harness.server.lastCall("social.grain.feed.getFeed")
        assertEquals("hashtag", call.params["feed"]?.substringAfterLast('/'))
        assertEquals("fog", call.params["tag"])
    }

    @Test
    fun aLocationFeedAsksForThatCellAndKeepsTheNameItWasGiven() {
        val model = location()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        assertEquals("Mission District", model.locationName)
        assertEquals("8928308280fffff", model.cell)

        val call = harness.server.lastCall("social.grain.feed.getFeed")
        assertEquals("location", call.params["feed"]?.substringAfterLast('/'))
        assertEquals("8928308280fffff", call.params["location"])
        // The index counts a place across every cell it folded in; the name
        // is what makes the feed cover the same ground.
        assertEquals("Mission District", call.params["name"])
    }

    @Test
    fun aCameraFeedAsksForThatCamera() {
        val model = CameraFeedViewModel(
            client = harness.client,
            auth = harness.auth,
            labelDefsCache = harness.labelDefinitions,
            savedStateHandle = SavedStateHandle(mapOf("name" to "Leica M6")),
        )
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        assertEquals("Leica M6", model.camera)

        val call = harness.server.lastCall("social.grain.feed.getFeed")
        assertEquals("camera", call.params["feed"]?.substringAfterLast('/'))
        assertEquals("Leica M6", call.params["camera"])
    }

    @Test
    fun anEmptyAnswerIsOnlyReportedAsEmptyOnceItHasAnswered() {
        harness.server.on("social.grain.feed.getFeed", """{"items":[]}""")
        val model = hashtag()

        val state = model.state.awaitValue("the answer") { !it.isLoading }
        assertTrue(state.isEmpty)
    }

    @Test
    fun aFailedLoadFallsBackToTheFeedsOwnWording() {
        harness.server.on("social.grain.feed.getFeed", """{"error":"no"}""", code = 500)
        val model = hashtag()

        val state = model.state.awaitValue("the error") { it.errorMessage != null }
        assertEquals("HTTP error 500", state.errorMessage)
        assertFalse(state.isRefreshing)

        model.dismissError()
        assertNull(model.state.value.errorMessage)
    }

    @Test
    fun pullingToRefreshShowsTheIndicatorAndReReads() {
        val model = hashtag()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
        val before = harness.server.countOf("social.grain.feed.getFeed")

        model.refresh()
        harness.server.awaitCall("social.grain.feed.getFeed", before + 1)
        model.state.awaitValue("the refresh to finish") { !it.isRefreshing }
    }

    @Test
    fun moreGalleriesAppendBehindTheCursor() {
        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery())}],"cursor":"c1"}""",
        )
        val model = hashtag()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery(rkey = "g2", title = "Second"))}]}""",
        )
        model.loadMore()

        val state = model.state.awaitValue("the second page") { it.galleries.size == 2 }
        assertEquals("Second", state.galleries[1].title)
        assertEquals("c1", harness.server.lastCall("social.grain.feed.getFeed").params["cursor"])
        assertFalse(state.isLoadingMore)
    }

    @Test
    fun aPageWithNoCursorIsTheEndOfTheFeed() {
        val model = hashtag()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
        val before = harness.server.countOf("social.grain.feed.getFeed")

        model.loadMore()
        settle()

        assertEquals(before, harness.server.countOf("social.grain.feed.getFeed"))
    }

    @Test
    fun favoritingFlipsTheHeartThenKeepsTheRecordUri() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/fav/1"}""")
        val model = hashtag()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.toggleFavorite(gallery.uri)

        assertEquals(13, model.state.value.galleries.single().favCount)
        model.state.awaitValue("the record uri") {
            it.galleries.single().viewer?.fav == "at://did:plc:me/fav/1"
        }
    }

    @Test
    fun aFavoriteThatFailsIsPutBack() {
        harness.server.on("com.atproto.repo.createRecord", """{"error":"no"}""", code = 500)
        val model = hashtag()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.toggleFavorite(gallery.uri)

        val state = model.state.awaitValue("the revert") { !it.galleries.single().isFavorited }
        assertEquals(12, state.galleries.single().favCount)
    }

    @Test
    fun unfavoritingDeletesTheRecord() {
        harness.server.on(
            "social.grain.feed.getFeed",
            """{"items":[${json(Fixtures.gallery(favUri = "at://did:plc:me/social.grain.favorite/f1"))}]}""",
        )
        val model = hashtag()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.toggleFavorite(gallery.uri)

        assertTrue(harness.server.awaitCall("com.atproto.repo.deleteRecord").body.contains("\"rkey\":\"f1\""))
        assertEquals(11, model.state.value.galleries.single().favCount)
    }

    @Test
    fun favoritingSomethingNotInTheFeedIsIgnored() {
        val model = hashtag()
        model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }

        model.toggleFavorite("at://nothing")
        settle()

        assertEquals(0, harness.server.countOf("com.atproto.repo.createRecord"))
    }

    @Test
    fun theCommentCountFollowsTheSheetOpenedOverACard() {
        val model = hashtag()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.setCommentCount(gallery.uri, 7)

        assertEquals(7, model.state.value.galleries.single().commentCount)
    }

    @Test
    fun deletingAGalleryRemovesTheCardOnlyOnceTheWriteLands() {
        val model = hashtag()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.deleteGallery(gallery.uri)

        model.state.awaitValue("the card to go") { it.galleries.isEmpty() }
    }

    @Test
    fun aDeleteThatFailsLeavesTheCardAndSaysSo() {
        harness.server.on(
            "com.atproto.repo.applyWrites",
            """{"error":"no"}""",
            code = 500,
        )
        val model = hashtag()
        val gallery = model.state.awaitValue("the first page") { it.galleries.isNotEmpty() }
            .galleries.single()

        model.deleteGallery(gallery.uri)

        val state = model.state.awaitValue("the error") { it.errorMessage != null }
        assertEquals("Couldn't delete that gallery.", state.errorMessage)
        assertEquals(1, state.galleries.size)
    }
}