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

grain.social/grain-android

1 collaborator
0
1
main
381 lines · 12.6 KB Raw History
package social.grain

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
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.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.model.AtLabel
import social.grain.data.model.GrainGallery
import social.grain.data.model.GrainPhoto
import social.grain.data.model.H3Location
import social.grain.data.model.LabelDefinition
import social.grain.data.model.LabelLocale
import social.grain.support.Fixtures
import social.grain.support.awaitContentDescription
import social.grain.support.awaitText
import social.grain.ui.components.GalleryCard
import social.grain.ui.theme.GrainTheme

/**
 * The feed card — the one component every screen in the app puts on screen.
 *
 * Most of what it does is invisible to a view model test: which photo the
 * height comes from, whether a labelled gallery is covered before it is drawn,
 * whether the alt badge appears at all. All of those fail silently, as a card
 * that simply looks wrong.
 */
@RunWith(AndroidJUnit4::class)
class GalleryCardTest {

    @get:Rule
    val composeRule = createComposeRule()

    private fun show(
        gallery: GrainGallery = Fixtures.gallery(),
        labelDefinitions: List<LabelDefinition> = emptyList(),
        onOpen: () -> Unit = {},
        onProfileTap: (String) -> Unit = {},
        onFavoriteToggle: () -> Unit = {},
        onCommentTap: () -> Unit = {},
        onShare: () -> Unit = {},
        onFavoritesTap: (() -> Unit)? = null,
        onLocationTap: ((String, String) -> Unit)? = null,
        onReport: (() -> Unit)? = null,
        onDelete: (() -> Unit)? = null,
    ) {
        composeRule.setContent {
            GrainTheme {
                GalleryCard(
                    gallery = gallery,
                    labelDefinitions = labelDefinitions,
                    onOpen = onOpen,
                    onProfileTap = onProfileTap,
                    onFavoriteToggle = onFavoriteToggle,
                    onCommentTap = onCommentTap,
                    onShare = onShare,
                    onFavoritesTap = onFavoritesTap,
                    onLocationTap = onLocationTap,
                    onReport = onReport,
                    onDelete = onDelete,
                    modifier = Modifier.fillMaxSize(),
                )
            }
        }
    }

    @Test
    fun aCardShowsTheAuthorTheCaptionAndTheCounts() {
        show()

        composeRule.onNodeWithText("Alice").assertIsDisplayed()
        composeRule.onNodeWithText("@alice.grain.social").assertIsDisplayed()
        composeRule.onNodeWithText("A walk in the fog").assertIsDisplayed()
        composeRule.onNodeWithText("Six frames from a grey morning").assertIsDisplayed()
        composeRule.onNodeWithText("12").assertIsDisplayed()
        composeRule.onNodeWithText("3").assertIsDisplayed()
    }

    @Test
    fun anAuthorWithNoDisplayNameFallsBackToTheirHandle() {
        show(gallery = Fixtures.gallery(creator = Fixtures.profile(displayName = null)))

        composeRule.awaitText("@alice.grain.social")
    }

    @Test
    fun aCountOfZeroIsLeftOffRatherThanDrawnAsAZero() {
        show(gallery = Fixtures.gallery(favCount = 0, commentCount = 0))

        composeRule.onNodeWithText("0").assertDoesNotExist()
    }

    @Test
    fun anUnfavoritedGalleryOffersToFavoriteIt() {
        show()
        composeRule.awaitContentDescription("Favorite")
    }

    @Test
    fun aFavoritedGalleryOffersToUndoIt() {
        show(gallery = Fixtures.gallery(favUri = "at://fav/1"))
        composeRule.awaitContentDescription("Unfavorite")
    }

    @Test
    fun theEngagementRowReportsEachTapToItsOwnCallback() {
        var favorited = 0
        var comments = 0
        var shared = 0
        show(
            onFavoriteToggle = { favorited += 1 },
            onCommentTap = { comments += 1 },
            onShare = { shared += 1 },
        )

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

        assertEquals(1, favorited)
        assertEquals(1, comments)
        assertEquals(1, shared)
    }

    @Test
    fun tappingTheTitleOpensTheGallery() {
        var opened = 0
        show(onOpen = { opened += 1 })

        composeRule.onNodeWithText("A walk in the fog").performClick()

        assertEquals(1, opened)
    }

    @Test
    fun aPlaceNameWithACellBehindItOpensThatLocation() {
        var tapped: Pair<String, String>? = null
        show(
            gallery = Fixtures.gallery(
                location = H3Location(value = "8928308280fffff", name = "Mission District"),
            ),
            onLocationTap = { cell, name -> tapped = cell to name },
        )

        composeRule.onNodeWithText("Mission District").performClick()

        assertEquals("8928308280fffff" to "Mission District", tapped)
    }

    @Test
    fun aPlaceWithNoCellIsPlainTextWithNothingToOpen() {
        var tapped = 0
        show(
            gallery = Fixtures.gallery(location = H3Location(value = "", name = "Somewhere")),
            onLocationTap = { _, _ -> tapped += 1 },
        )

        composeRule.onNodeWithText("Somewhere").performClick()

        assertEquals("There is no feed to open without a cell", 0, tapped)
    }

    @Test
    fun theOverflowIsAbsentWhenThereIsNothingToPutInIt() {
        show()
        composeRule.onNodeWithContentDescription("More options").assertDoesNotExist()
    }

    @Test
    fun theOverflowOffersReportingAndDeletingWhenBothArePossible() {
        var reported = 0
        show(onReport = { reported += 1 }, onDelete = {})

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

        assertEquals(1, reported)
    }

    @Test
    fun theOverflowDeleteEntryReportsItsOwnTap() {
        var deleted = 0
        show(onDelete = { deleted += 1 })

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

        assertEquals(1, deleted)
    }

    @Test
    fun aPhotoWithADescriptionAdvertisesItAndOpensItOnTap() {
        show(
            gallery = Fixtures.gallery(photos = 0).copy(
                items = listOf(Fixtures.photo(alt = "A tree in fog")),
            ),
        )

        composeRule.onNodeWithContentDescription("Show alt text").performClick()
        composeRule.awaitText("A tree in fog")
        composeRule.awaitContentDescription("Hide alt text")
    }

    @Test
    fun aPhotoWithNoDescriptionAdvertisesNothing() {
        show()
        composeRule.onNodeWithContentDescription("Show alt text").assertDoesNotExist()
    }

    @Test
    fun aGalleryLabelledForContentIsCoveredUntilTheReaderAsks() {
        show(gallery = Fixtures.gallery(labels = listOf(AtLabel(`val` = "!warn"))))

        composeRule.onNodeWithText("A walk in the fog").assertDoesNotExist()
        composeRule.onNodeWithText("Show anyway").performClick()
        composeRule.onNodeWithText("A walk in the fog").assertIsDisplayed()
    }

    @Test
    fun aGalleryLabelledForMediaKeepsItsCaptionAndCoversOnlyThePhotos() {
        show(gallery = Fixtures.gallery(labels = listOf(AtLabel(`val` = "porn"))))

        composeRule.onNodeWithText("A walk in the fog").assertIsDisplayed()
        composeRule.awaitText("Adult Content")
    }

    @Test
    fun aLabelThatOnlyBadgesLeavesTheCardAloneAndNamesItself() {
        show(
            gallery = Fixtures.gallery(labels = listOf(AtLabel(`val` = "spoiler"))),
            labelDefinitions = listOf(
                LabelDefinition(
                    identifier = "spoiler",
                    locales = listOf(LabelLocale("Spoiler")),
                    blurs = "none",
                    defaultSetting = "warn",
                ),
            ),
        )

        composeRule.onNodeWithText("A walk in the fog").assertIsDisplayed()
        composeRule.awaitText("Spoiler")
    }

    @Test
    fun aBadgeKeepsTheCaptionBlockAliveOnAGalleryWithNoWords() {
        show(
            gallery = Fixtures.gallery(
                title = null,
                description = null,
                labels = listOf(AtLabel(`val` = "some-new-label")),
            ),
        )

        composeRule.awaitText("some-new-label")
    }

    @Test
    fun aGalleryWithNoWordsAtAllDrawsNoCaptionBlock() {
        show(gallery = Fixtures.gallery(title = null, description = null))

        composeRule.onNodeWithText("Six frames from a grey morning").assertDoesNotExist()
    }

    @Test
    fun theSocialProofLineNamesUpToTwoPeopleAndCountsTheRest() {
        val followers = listOf(
            Fixtures.profile(did = "did:plc:bob", handle = "bob.test", displayName = "Bob"),
            Fixtures.profile(did = "did:plc:carol", handle = "carol.test", displayName = "Carol"),
            Fixtures.profile(did = "did:plc:dave", handle = "dave.test", displayName = "Dave"),
        )
        var opened = 0
        show(
            gallery = Fixtures.gallery().copy(favedByFollowing = followers),
            onFavoritesTap = { opened += 1 },
        )

        composeRule.onNodeWithText("Favorited by Bob, Carol and others you follow").performClick()
        assertEquals(1, opened)
    }

    @Test
    fun twoPeopleAreNamedWithAnAnd() {
        show(
            gallery = Fixtures.gallery().copy(
                favedByFollowing = listOf(
                    Fixtures.profile(did = "did:plc:bob", displayName = "Bob"),
                    Fixtures.profile(did = "did:plc:carol", displayName = "Carol"),
                ),
            ),
        )
        composeRule.awaitText("Favorited by Bob and Carol")
    }

    @Test
    fun onePersonIsNamedOnTheirOwn() {
        show(
            gallery = Fixtures.gallery().copy(
                favedByFollowing = listOf(Fixtures.profile(displayName = "Bob")),
            ),
        )
        composeRule.awaitText("Favorited by Bob")
    }

    @Test
    fun aGalleryNobodyYouFollowFavoritedHasNoProofLine() {
        show()
        composeRule.onNodeWithText("Favorited by Bob", substring = true).assertDoesNotExist()
    }

    @Test
    fun aGalleryWithNoPhotosStillDrawsItsHeaderAndCaption() {
        show(gallery = Fixtures.gallery(photos = 0))

        composeRule.onNodeWithText("A walk in the fog").assertIsDisplayed()
    }

    @Test
    fun cameraDataIsShownWhenAPhotoCarriesIt() {
        val photo = Fixtures.photo().copy(
            exif = social.grain.data.model.GrainExif(
                make = "Fujifilm",
                model = "X100V",
                fNumber = "2.0",
                exposureTime = "1/500",
                focalLengthIn35mmFormat = "35",
                iSO = 400,
            ),
        )
        show(gallery = Fixtures.gallery(photos = 0).copy(items = listOf(photo)))

        composeRule.awaitText("Fujifilm X100V")
        composeRule.awaitText("35mm  ·  f/2  ·  1/500s  ·  ISO 400")
    }

    @Test
    fun aMixedGalleryStillRendersEveryFrameItWasGiven() {
        val photos = listOf(
            GrainPhoto(uri = "at://p/1", aspectRatio = social.grain.data.model.AspectRatio(3, 2)),
            GrainPhoto(uri = "at://p/2", aspectRatio = social.grain.data.model.AspectRatio(9, 16)),
            GrainPhoto(uri = "at://p/3", aspectRatio = social.grain.data.model.AspectRatio(1, 1)),
        )
        show(gallery = Fixtures.gallery(photos = 0).copy(items = photos))

        // Exists rather than displayed: a portrait gallery is taller than the
        // window, so the caption is below the fold on a card this size.
        composeRule.awaitText("A walk in the fog")
    }

    @Test
    fun aPhotoWithADegenerateRatioDoesNotTakeTheCardDown() {
        show(
            gallery = Fixtures.gallery(photos = 0).copy(
                items = listOf(
                    GrainPhoto(uri = "at://p/1", aspectRatio = social.grain.data.model.AspectRatio(0, 0)),
                ),
            ),
        )

        composeRule.onNodeWithText("A walk in the fog").assertIsDisplayed()
    }

    @Test
    fun tappingTheAuthorOpensTheirProfile() {
        var opened: String? = null
        show(onProfileTap = { opened = it })

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

        assertTrue(opened == "did:plc:alice")
    }
}