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 = 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? = 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") } }