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

grain.social/grain-android

1 collaborator
0
1
main
440 lines · 14.6 KB Raw History
package social.grain

import android.app.Application
import android.content.Intent
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows
import social.grain.data.location.LocationServices
import social.grain.feature.actors.ActorListMode
import social.grain.feature.actors.ActorListScreen
import social.grain.feature.actors.ActorListViewModel
import social.grain.feature.feed.HashtagFeedScreen
import social.grain.feature.feed.HashtagFeedViewModel
import social.grain.feature.feed.LocationFeedScreen
import social.grain.feature.feed.LocationFeedViewModel
import social.grain.feature.gallery.GalleryDetailScreen
import social.grain.feature.gallery.GalleryDetailViewModel
import social.grain.feature.settings.ModerationMode
import social.grain.feature.settings.ModerationScreen
import social.grain.feature.settings.ModerationViewModel
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.awaitContentDescription
import social.grain.support.awaitText
import social.grain.support.MainDispatcherRule
import social.grain.support.awaitCall
import social.grain.support.json
import social.grain.support.testJson
import social.grain.ui.theme.GrainTheme

/**
 * The screens that are a list, a title bar and an empty state.
 *
 * Grouped because that is genuinely all they are: each wraps one view model
 * already tested on its own, and what is worth pinning here is the shell — the
 * right title, the right words when there is nothing to show, and a back button
 * that reports its tap.
 */
@RunWith(AndroidJUnit4::class)
class ScreenShellTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    @get:Rule
    val composeRule = createComposeRule()

    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()

    // MARK: - Actor lists

    private fun actorList(
        mode: ActorListMode,
        subject: String = "did:plc:alice",
        onBack: () -> Unit = {},
        onOpenProfile: (String) -> Unit = {},
    ) {
        val model = ActorListViewModel(
            harness.client,
            harness.auth,
            SavedStateHandle(mapOf("mode" to mode, "subject" to subject)),
        )
        composeRule.setContent {
            GrainTheme {
                ActorListScreen(onBack = onBack, onOpenProfile = onOpenProfile, viewModel = model)
            }
        }
        composeRule.waitForIdle()
    }

    @Test
    fun aFollowerListIsTitledAndListsWhoItWasGiven() {
        harness.server.on(
            "social.grain.graph.getFollowers",
            """{"totalCount":1,"items":[{"did":"did:plc:bob","handle":"bob.test","displayName":"Bob"}]}""",
        )
        var opened: String? = null
        actorList(ActorListMode.FOLLOWERS, onOpenProfile = { opened = it })

        composeRule.awaitText("Followers")
        composeRule.awaitText("Bob")

        composeRule.onNodeWithText("Bob").performClick()
        assertEquals("did:plc:bob", opened)
    }

    @Test
    fun anEmptyFollowerListSaysWhoItIsAboutRatherThanNothing() {
        harness.server.on("social.grain.graph.getFollowers", """{"items":[]}""")
        actorList(ActorListMode.FOLLOWERS)

        composeRule.awaitText("No followers")
        composeRule.awaitText("No one is following this account yet.")
    }

    @Test
    fun anEmptyFavoritesListHasItsOwnWording() {
        harness.server.on("social.grain.gallery.getFavorites", """{"items":[]}""")
        actorList(ActorListMode.GALLERY_FAVORITES, subject = "at://x/gallery/1")

        composeRule.awaitText("Favorited by")
        composeRule.awaitText("No one has favorited this gallery yet.")
    }

    @Test
    fun theActorListBackButtonReportsItsTap() {
        harness.server.on("social.grain.graph.getFollowing", """{"items":[]}""")
        var back = 0
        actorList(ActorListMode.FOLLOWING, onBack = { back += 1 })

        composeRule.onNodeWithContentDescription("Back").performClick()

        assertEquals(1, back)
    }

    // MARK: - Moderation lists

    private fun moderation(mode: ModerationMode, onBack: () -> Unit = {}) {
        val model = ModerationViewModel(
            harness.client,
            harness.auth,
            SavedStateHandle(mapOf("mode" to mode)),
        )
        composeRule.setContent {
            GrainTheme {
                ModerationScreen(onBack = onBack, onOpenProfile = {}, viewModel = model)
            }
        }
        composeRule.waitForIdle()
    }

    @Test
    fun theBlockedListNamesWhoIsBlockedAndOffersToUndoIt() {
        harness.server.on(
            "social.grain.graph.getBlocks",
            """{"items":[{"did":"did:plc:bob","handle":"bob.test","displayName":"Bob",""" +
                """"blockUri":"at://did:plc:me/social.grain.graph.block/b1"}]}""",
        )
        moderation(ModerationMode.BLOCKED)

        composeRule.awaitText("Blocked users")
        composeRule.awaitText("Bob")
        composeRule.onNodeWithText("Unblock").performClick()

        composeRule.waitUntil(10_000) { harness.server.countOf("com.atproto.repo.deleteRecord") >= 1 }
    }

    @Test
    fun anEmptyMutedListSaysSo() {
        harness.server.on("social.grain.graph.getMutes", """{"items":[]}""")
        var back = 0
        moderation(ModerationMode.MUTED, onBack = { back += 1 })

        composeRule.awaitText("Muted users")
        composeRule.awaitText("You haven't muted anyone.")

        composeRule.onNodeWithContentDescription("Back").performClick()
        assertEquals(1, back)
    }

    // MARK: - Gallery detail

    private fun galleryDetail(onBack: () -> Unit = {}) {
        val model = GalleryDetailViewModel(
            harness.client,
            harness.auth,
            harness.labelDefinitions,
            SavedStateHandle(mapOf("uri" to "at://did:plc:alice/social.grain.gallery/gallery1")),
        )
        composeRule.setContent {
            GrainTheme {
                GalleryDetailScreen(onBack = onBack, onOpenProfile = {}, viewModel = model)
            }
        }
        composeRule.waitForIdle()
    }

    @Test
    fun theGalleryScreenDrawsTheCardAndItsCommentPrompt() {
        harness.server
            .on(
                "social.grain.gallery.getGallery",
                """{"gallery":${json(Fixtures.gallery())}}""",
            )
            .on(
                "social.grain.comment.getThread",
                """{"comments":[${json(Fixtures.comment())}],"totalCount":3}""",
            )
        galleryDetail()

        composeRule.awaitText("Gallery")
        composeRule.awaitText("A walk in the fog")
        composeRule.awaitText("View all 3 comments")
    }

    @Test
    fun aGalleryWithNoCommentsInvitesTheFirstOne() {
        harness.server
            .on(
                "social.grain.gallery.getGallery",
                """{"gallery":${json(Fixtures.gallery(commentCount = 0))}}""",
            )
            .on("social.grain.comment.getThread", """{"comments":[],"totalCount":0}""")
        galleryDetail()

        composeRule.awaitText("Add a comment…")
    }

    @Test
    fun aGalleryThatIsGoneSaysSoRatherThanSpinningForever() {
        harness.server
            .on("social.grain.gallery.getGallery", """{"error":"gone"}""", code = 404)
            .on("social.grain.comment.getThread", """{"comments":[]}""")
        var back = 0
        galleryDetail(onBack = { back += 1 })

        composeRule.awaitText("Gallery unavailable")

        composeRule.onNodeWithContentDescription("Back").performClick()
        assertEquals(1, back)
    }

    @Test
    fun theViewersOwnGalleryOffersToDeleteIt() {
        harness.server
            .on(
                "social.grain.gallery.getGallery",
                """{"gallery":${json(
                    Fixtures.gallery(
                        creator = Fixtures.profile(did = "did:plc:me", handle = "me.test"),
                    ),
                )}}""",
            )
            .on("social.grain.comment.getThread", """{"comments":[]}""")
        galleryDetail()

        composeRule.onNodeWithContentDescription("Delete gallery").performClick()
        composeRule.awaitText("Delete gallery?")
        composeRule.onNodeWithText("Cancel").performClick()
        composeRule.waitForIdle()

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

    // MARK: - Tapped-through feeds

    @Test
    fun aHashtagFeedIsTitledAfterItsTagAndListsWhatItFound() {
        var back = 0
        val model = HashtagFeedViewModel(
            harness.client,
            harness.auth,
            harness.labelDefinitions,
            SavedStateHandle(mapOf("tag" to "fog")),
        )
        composeRule.setContent {
            GrainTheme {
                HashtagFeedScreen(
                    onBack = { back += 1 },
                    onOpenProfile = {},
                    onOpenHashtag = {},
                    viewModel = model,
                )
            }
        }
        composeRule.waitForIdle()

        composeRule.awaitText("#fog")
        composeRule.awaitText("A walk in the fog")

        composeRule.onNodeWithContentDescription("Back").performClick()
        assertEquals(1, back)
    }

    @Test
    fun anEmptyHashtagFeedNamesTheTagThatFoundNothing() {
        harness.server.on("social.grain.feed.getFeed", """{"items":[]}""")
        val model = HashtagFeedViewModel(
            harness.client,
            harness.auth,
            harness.labelDefinitions,
            SavedStateHandle(mapOf("tag" to "quiet")),
        )
        composeRule.setContent {
            GrainTheme {
                HashtagFeedScreen(
                    onBack = {},
                    onOpenProfile = {},
                    onOpenHashtag = {},
                    viewModel = model,
                )
            }
        }
        composeRule.waitForIdle()

        composeRule.awaitText("Nothing tagged #quiet yet")
    }

    @Test
    fun aLocationFeedIsTitledWithThePlaceNameTheReaderTapped() {
        val model = LocationFeedViewModel(
            harness.client,
            harness.auth,
            LocationServices(OkHttpClient(), testJson),
            harness.labelDefinitions,
            SavedStateHandle(mapOf("cell" to "8928308280fffff", "name" to "Mission District")),
        )
        composeRule.setContent {
            GrainTheme {
                LocationFeedScreen(
                    onBack = {},
                    onOpenProfile = {},
                    onOpenHashtag = {},
                    viewModel = model,
                )
            }
        }
        composeRule.waitForIdle()

        composeRule.awaitText("Mission District")
        composeRule.awaitText("A walk in the fog")
    }

    @Test
    fun anEmptyLocationFeedNamesThePlaceThatFoundNothing() {
        harness.server.on("social.grain.feed.getFeed", """{"items":[]}""")
        val model = LocationFeedViewModel(
            harness.client,
            harness.auth,
            LocationServices(OkHttpClient(), testJson),
            harness.labelDefinitions,
            SavedStateHandle(mapOf("cell" to "89283", "name" to "Nowhere")),
        )
        composeRule.setContent {
            GrainTheme {
                LocationFeedScreen(
                    onBack = {},
                    onOpenProfile = {},
                    onOpenHashtag = {},
                    viewModel = model,
                )
            }
        }
        composeRule.waitForIdle()

        composeRule.awaitText("Nothing from Nowhere yet")
    }

    @Test
    fun theViewersOwnGalleryInAFeedIsDeletedOnlyOnceConfirmed() {
        val mine = Fixtures.gallery(creator = Fixtures.profile(did = "did:plc:me", handle = "me.test"))
        harness.server
            .on("social.grain.feed.getFeed", """{"items":[${json(mine)}]}""")
            .on("com.atproto.repo.applyWrites", "{}")
        val model = HashtagFeedViewModel(
            harness.client,
            harness.auth,
            harness.labelDefinitions,
            SavedStateHandle(mapOf("tag" to "fog")),
        )
        composeRule.setContent {
            GrainTheme {
                HashtagFeedScreen(
                    onBack = {},
                    onOpenProfile = {},
                    onOpenHashtag = {},
                    viewModel = model,
                )
            }
        }
        composeRule.awaitText("A walk in the fog")

        composeRule.onNodeWithContentDescription("More options").performClick()
        composeRule.onNodeWithText("Delete gallery").performClick()
        composeRule.awaitText("Delete gallery?")
        composeRule.onNodeWithText("Cancel").performClick()
        composeRule.waitForIdle()
        assertEquals(0, harness.server.countOf("com.atproto.repo.applyWrites"))

        composeRule.onNodeWithContentDescription("More options").performClick()
        composeRule.onNodeWithText("Delete gallery").performClick()
        composeRule.awaitText("Delete gallery?")
        composeRule.onNodeWithText("Delete").performClick()

        harness.server.awaitCall("com.atproto.repo.applyWrites")
        composeRule.awaitText("Nothing tagged #fog yet")
    }

    @Test
    fun sharingAFeedCardHandsItsLinkToTheSystemChooser() {
        val model = HashtagFeedViewModel(
            harness.client,
            harness.auth,
            harness.labelDefinitions,
            SavedStateHandle(mapOf("tag" to "fog")),
        )
        composeRule.setContent {
            GrainTheme {
                HashtagFeedScreen(
                    onBack = {},
                    onOpenProfile = {},
                    onOpenHashtag = {},
                    viewModel = model,
                )
            }
        }
        composeRule.awaitText("A walk in the fog")

        composeRule.onNodeWithContentDescription("Share").performClick()

        val chooser = Shadows.shadowOf(ApplicationProvider.getApplicationContext<Application>())
            .nextStartedActivity
        assertEquals(Intent.ACTION_CHOOSER, chooser.action)
        @Suppress("DEPRECATION")
        val send = chooser.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
        assertEquals(Fixtures.gallery().shareUrl, send.getStringExtra(Intent.EXTRA_TEXT))
    }
}