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

grain.social/grain-android

1 collaborator
0
1
main
285 lines · 9.2 KB Raw History
package social.grain

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.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeLeft
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.model.GrainProfile
import social.grain.feature.stories.StoryCommentsViewModel
import social.grain.feature.stories.StoryViewerScreen
import social.grain.feature.stories.StoryViewerViewModel
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.MainDispatcherRule
import social.grain.support.awaitContentDescription
import social.grain.support.awaitText
import social.grain.support.json
import social.grain.ui.theme.GrainTheme

/**
 * The story viewer as it appears: a full-bleed photo with controls over it.
 *
 * Almost everything here is drawn in white over an unknown photograph, and the
 * whole screen has no chrome to fall back on — a control that fails to compose
 * leaves a reader tapping a black rectangle with no way out.
 */
@RunWith(AndroidJUnit4::class)
class StoryViewerScreenTest {

    @get:Rule
    val mainDispatcher = MainDispatcherRule()

    @get:Rule
    val composeRule = createComposeRule()

    private lateinit var harness: Harness

    private val alice = Fixtures.profile(did = "did:plc:alice", handle = "alice.test")

    private fun stories(creator: GrainProfile, vararg rkeys: String) =
        """{"stories":[${rkeys.joinToString(",") {
            json(Fixtures.story(rkey = it, creator = creator))
        }}]}"""

    @Before
    fun setUp() {
        harness = Harness()
        harness.server
            .on("social.grain.story.getStories", stories(alice, "a1", "a2"))
            .on("social.grain.comment.getThread", """{"comments":[],"totalCount":0}""")
    }

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

    private lateinit var viewer: StoryViewerViewModel

    private fun show(
        did: String = alice.did,
        story: String = "",
        solo: Boolean = true,
        onClose: () -> Unit = {},
        onOpenProfile: (String) -> Unit = {},
    ) {
        viewer = StoryViewerViewModel(
            client = harness.client,
            auth = harness.auth,
            viewedStore = harness.viewedStories,
            statusCache = harness.storyStatus,
            labelDefsCache = harness.labelDefinitions,
            savedStateHandle = SavedStateHandle(
                mapOf("did" to did, "story" to story, "solo" to solo),
            ),
        )
        val comments = StoryCommentsViewModel(harness.client, harness.auth)
        composeRule.setContent {
            GrainTheme {
                StoryViewerScreen(
                    onClose = onClose,
                    onOpenProfile = onOpenProfile,
                    viewModel = viewer,
                    commentsViewModel = comments,
                )
            }
        }
        composeRule.waitForIdle()
    }

    @Test
    fun theViewerNamesTheAuthorAndOffersAWayOut() {
        show()

        composeRule.awaitText("Alice")
        composeRule.awaitContentDescription("Close")
    }

    @Test
    fun closingReportsItsTap() {
        var closed = 0
        show(onClose = { closed += 1 })
        composeRule.awaitContentDescription("Close")

        composeRule.onNodeWithContentDescription("Close").performClick()

        // Leaving plays a short animation before it hands back.
        composeRule.waitUntil(10_000) { closed == 1 }
    }

    @Test
    fun aStoryOffersToBeFavoritedAndCommentedOn() {
        show()

        composeRule.awaitContentDescription("Favorite")
        composeRule.awaitContentDescription("Comments")
    }

    @Test
    fun favoritingWritesTheRecord() {
        harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/fav/1"}""")
        show()
        composeRule.awaitContentDescription("Favorite")

        composeRule.onNodeWithContentDescription("Favorite").performClick()

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

    @Test
    fun anAlreadyFavoritedStoryOffersTheUndo() {
        harness.server.on(
            "social.grain.story.getStories",
            """{"stories":[${json(
                Fixtures.story(rkey = "a1", creator = alice, favUri = "at://fav/1"),
            )}]}""",
        )
        show()

        composeRule.awaitContentDescription("Unfavorite")
    }

    @Test
    fun somebodyElsesStoryOffersReportingRatherThanDeleting() {
        show()

        composeRule.awaitContentDescription("Report story")
        composeRule.onNodeWithContentDescription("Delete story").assertDoesNotExist()
    }

    @Test
    fun theViewersOwnStoryOffersDeletingBehindAConfirmation() {
        val me = Fixtures.profile(did = harness.did, handle = harness.handle)
        harness.server.on("social.grain.story.getStories", stories(me, "m1"))
        show(did = harness.did)
        composeRule.awaitContentDescription("Delete story")

        composeRule.onNodeWithContentDescription("Delete story").performClick()
        composeRule.awaitText("Delete story?")
        composeRule.onNodeWithText("This will permanently delete this story.").assertExists()

        composeRule.onNodeWithText("Cancel").performClick()
        composeRule.waitForIdle()

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

    @Test
    fun confirmingTheDeleteWritesIt() {
        val me = Fixtures.profile(did = harness.did, handle = harness.handle)
        harness.server.on("social.grain.story.getStories", stories(me, "m1"))
        show(did = harness.did)
        composeRule.awaitContentDescription("Delete story")

        composeRule.onNodeWithContentDescription("Delete story").performClick()
        composeRule.awaitText("Delete story?")
        composeRule.onNodeWithText("Delete").performClick()

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

    @Test
    fun tappingTheAuthorOpensTheirProfile() {
        var opened: String? = null
        show(onOpenProfile = { opened = it })
        composeRule.awaitText("Alice")

        composeRule.onNodeWithText("Alice").performClick()

        composeRule.waitUntil(10_000) { opened == "did:plc:alice" }
    }

    // MARK: - Gestures

    private val bob =
        Fixtures.profile(did = "did:plc:bob", handle = "bob.test", displayName = "Bob")

    /** Two authors in the strip, alice first — the strip is newest-first. */
    private fun seedStrip() {
        harness.storyStatus.update(
            listOf(
                Fixtures.storyAuthor(
                    profile = alice,
                    storyCount = 2,
                    latestAt = Fixtures.recentIso(minutesAgo = 1),
                ),
                Fixtures.storyAuthor(
                    profile = bob,
                    storyCount = 1,
                    latestAt = Fixtures.recentIso(minutesAgo = 30),
                ),
            ),
        )
        harness.server.on("social.grain.story.getStories") { call ->
            when (call.params["actor"]) {
                bob.did -> stories(bob, "b1")
                else -> stories(alice, "a1", "a2")
            }
        }
    }

    @Test
    fun draggingSidewaysHandsOverToTheNextAuthor() {
        seedStrip()
        show(solo = false)
        composeRule.awaitText("Alice")

        composeRule.onRoot().performTouchInput {
            swipeLeft(startX = right * 0.9f, endX = left + 1f, durationMillis = 200)
        }

        composeRule.waitUntil(10_000) { viewer.state.value.author?.did == bob.did }
    }

    @Test
    fun draggingDownThrowsTheViewerAway() {
        seedStrip()
        var closed = 0
        show(solo = false, onClose = { closed += 1 })
        composeRule.awaitText("Alice")

        composeRule.onRoot().performTouchInput {
            swipeDown(startY = top + 1f, endY = bottom - 1f, durationMillis = 200)
        }

        composeRule.waitUntil(10_000) { closed == 1 || viewer.state.value.isFinished }
    }

    @Test
    fun aDragThatGoesNowhereLeavesTheStoryWhereItWas() {
        seedStrip()
        show(solo = false)
        composeRule.awaitText("Alice")

        composeRule.onRoot().performTouchInput {
            down(center)
            moveTo(center + androidx.compose.ui.geometry.Offset(20f, 0f))
            up()
        }
        composeRule.waitForIdle()

        assertEquals(alice.did, viewer.state.value.author?.did)
    }

    @Test
    fun anArchivedStoryOpensOnItsOwn() {
        harness.server.on(
            "social.grain.story.getStory",
            """{"story":${json(Fixtures.story(rkey = "old", creator = alice, expired = true))}}""",
        )
        show(story = "at://did:plc:alice/social.grain.story/old", solo = false)

        composeRule.awaitText("Alice")
        assertEquals(1, viewer.state.value.stories.size)
    }
}