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

grain.social/grain-android

1 collaborator
0
1
main
293 lines · 9.4 KB Raw History
package social.grain

import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
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.ext.junit.runners.AndroidJUnit4
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 social.grain.data.local.ProfileInvalidation
import social.grain.feature.profile.ProfileFeedSource
import social.grain.feature.profile.ProfileScreen
import social.grain.feature.profile.ProfileViewModel
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.ui.theme.GrainTheme

/**
 * A profile, which is really two screens: your own and somebody else's.
 *
 * The differences are all in the composable rather than the view model — a
 * settings button instead of a follow button, three tabs instead of one, and an
 * overflow that offers to block an account you can't block yourself with.
 */
@RunWith(AndroidJUnit4::class)
class ProfileScreenTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    @get:Rule
    val composeRule = createComposeRule()

    private lateinit var harness: Harness

    private val alice = "did:plc:alice"

    @Before
    fun setUp() {
        harness = Harness()
        harness.server
            .on("social.grain.actor.getProfile", json(Fixtures.profileDetailed()))
            .on("social.grain.feed.getFeed", """{"items":[${json(Fixtures.gallery())}]}""")
            .on("social.grain.story.getStories", """{"stories":[]}""")
            .on("social.grain.actor.getFavorites", """{"items":[]}""")
            .on("social.grain.story.getArchive", """{"stories":[]}""")
    }

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

    private fun show(
        did: String? = alice,
        onOpenGallery: (String, ProfileFeedSource) -> Unit = { _, _ -> },
        onOpenFollowers: (String) -> Unit = {},
        onOpenFollowing: (String) -> Unit = {},
        onBack: (() -> Unit)? = {},
        onOpenSettings: (() -> Unit)? = null,
    ) {
        val model = ProfileViewModel(
            client = harness.client,
            auth = harness.auth,
            profileInvalidation = ProfileInvalidation(),
            labelDefsCache = harness.labelDefinitions,
            savedStateHandle = SavedStateHandle(
                if (did == null) emptyMap() else mapOf("did" to did),
            ),
        )
        composeRule.setContent {
            GrainTheme {
                ProfileScreen(
                    onOpenGallery = onOpenGallery,
                    onOpenFollowers = onOpenFollowers,
                    onOpenFollowing = onOpenFollowing,
                    onBack = onBack,
                    onOpenSettings = onOpenSettings,
                    viewModel = model,
                )
            }
        }
        // The header is what everything else hangs off, and under a loaded
        // machine the first fetch can outlast the first frame.
        composeRule.waitUntil(10_000) {
            composeRule.onAllNodes(hasText("@", substring = true))
                .fetchSemanticsNodes().isNotEmpty()
        }
    }

    private fun ownProfile() = json(
        Fixtures.profileDetailed(did = harness.did, handle = harness.handle, displayName = "Me"),
    )

    @Test
    fun aProfileShowsItsNameBioAndCounts() {
        show()

        composeRule.awaitText("Alice")
        composeRule.awaitText("@alice.grain.social")
        composeRule.awaitText("Photographs of things")
        composeRule.awaitText("Galleries")
        composeRule.awaitText("Followers")
        composeRule.awaitText("Following")
    }

    @Test
    fun theFollowerCountsLeadToTheirLists() {
        var followers = 0
        var following = 0
        show(onOpenFollowers = { followers += 1 }, onOpenFollowing = { following += 1 })

        composeRule.onNodeWithText("Followers").performClick()
        composeRule.onNodeWithText("Following").performClick()

        assertEquals(1, followers)
        assertEquals(1, following)
    }

    @Test
    fun someoneYouDoNotFollowIsOfferedAPlainFollowButton() {
        show()
        composeRule.awaitText("Follow")
    }

    @Test
    fun someoneWhoFollowsYouIsOfferedAFollowBack() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(followedBy = "at://follow/2")),
        )
        show()

        composeRule.awaitText("Follows you")
        composeRule.awaitText("Follow back")
    }

    @Test
    fun tappingFollowWritesTheRecord() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/follow/1"}""")
        show()

        composeRule.onNodeWithText("Follow").performClick()

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

    @Test
    fun anAccountYouAlreadyFollowSaysFollowing() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(following = "at://follow/1")),
        )
        show()

        // Two of them: the follow button and the stat it sits beside.
        composeRule.onAllNodesWithText("Following").assertCountEquals(2)
    }

    @Test
    fun theOverflowOffersModerationForSomebodyElse() {
        show()

        composeRule.onNodeWithContentDescription("More options").performClick()

        composeRule.awaitText("Share profile")
        composeRule.awaitText("Copy username")
        composeRule.awaitText("Mute")
        composeRule.awaitText("Block")
        composeRule.awaitText("Report account")
    }

    @Test
    fun blockingGoesThroughAConfirmationNamingTheAccount() {
        show()

        composeRule.onNodeWithContentDescription("More options").performClick()
        composeRule.onNodeWithText("Block").performClick()

        composeRule.awaitText("Block @alice.grain.social?")
        composeRule.onNodeWithText("Cancel").performClick()
        composeRule.waitForIdle()

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

    @Test
    fun anAlreadyMutedAccountIsOfferedTheUndo() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(muted = true)),
        )
        show()

        composeRule.onNodeWithContentDescription("More options").performClick()
        composeRule.onNodeWithText("Unmute").performClick()

        composeRule.waitUntil(10_000) { harness.server.countOf("social.grain.graph.unmuteActor") >= 1 }
    }

    @Test
    fun aBlockedAccountShowsNoGalleriesAndSaysWhy() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed(blocking = "at://block/1")),
        )
        show()

        composeRule.awaitText("Account blocked")
        composeRule.onNodeWithText("A walk in the fog").assertDoesNotExist()
    }

    @Test
    fun anAccountThatBlockedYouSaysSoTheOtherWayRound() {
        harness.server.on(
            "social.grain.actor.getProfile",
            json(Fixtures.profileDetailed()).replace(
                """"muted":null""",
                """"blockedBy":true""",
            ).let {
                json(Fixtures.profileDetailed()).dropLast(1) +
                    ""","viewer":{"blockedBy":true}}"""
            },
        )
        show()

        composeRule.onNodeWithText("This user has blocked you", useUnmergedTree = true)
            .assertExists()
    }

    @Test
    fun theViewersOwnProfileGetsSettingsRatherThanFollow() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        var settings = 0
        show(did = harness.did, onBack = null, onOpenSettings = { settings += 1 })

        composeRule.onNodeWithText("Follow").assertDoesNotExist()
        composeRule.onNodeWithContentDescription("Settings").performClick()

        assertEquals(1, settings)
    }

    @Test
    fun theViewersOwnProfileHasFavoritesAndStoriesTabs() {
        harness.server.on("social.grain.actor.getProfile", ownProfile())
        show(did = harness.did, onBack = null, onOpenSettings = {})

        composeRule.awaitContentDescription("Favorites")
        composeRule.awaitContentDescription("Stories")
    }

    @Test
    fun aGridWithNothingInItSaysWhichTabIsEmpty() {
        harness.server.on("social.grain.feed.getFeed", """{"items":[]}""")
        show()

        composeRule.awaitText("No galleries yet")
    }

    @Test
    fun tappingATileOpensTheFeedOnThatGallery() {
        var opened: Pair<String, ProfileFeedSource>? = null
        show(onOpenGallery = { uri, source -> opened = uri to source })

        composeRule.onNodeWithContentDescription("Multiple photos").performClick()

        assertEquals(
            "at://did:plc:alice/social.grain.gallery/gallery1" to ProfileFeedSource.GALLERIES,
            opened,
        )
    }

    @Test
    fun theBackButtonIsThereOnSomebodyElsesProfile() {
        var back = 0
        show(onBack = { back += 1 })

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

        assertEquals(1, back)
    }
}