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.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("dev.hatk.describeLabels", """{"definitions":[]}""") .on("social.grain.unspecced.getActorProfile", json(Fixtures.profileDetailed())) .on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") .on("social.grain.unspecced.getStories", """{"stories":[]}""") .on("social.grain.unspecced.getActorFavorites", """{"items":[]}""") .on("social.grain.unspecced.getStoryArchive", """{"stories":[]}""") } @After fun tearDown() = harness.tearDown() private fun show( did: String? = alice, onOpenGallery: (String) -> 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.unspecced.getActorProfile", json(Fixtures.profileDetailed(followedBy = "at://follow/2")), ) show() composeRule.awaitText("Follows you") composeRule.awaitText("Follow back") } @Test fun tappingFollowWritesTheRecord() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/follow/1"}""") show() composeRule.onNodeWithText("Follow").performClick() composeRule.waitUntil(10_000) { harness.server.countOf("dev.hatk.createRecord") >= 1 } } @Test fun anAccountYouAlreadyFollowSaysFollowing() { harness.server.on( "social.grain.unspecced.getActorProfile", 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("dev.hatk.createRecord")) } @Test fun anAlreadyMutedAccountIsOfferedTheUndo() { harness.server.on( "social.grain.unspecced.getActorProfile", 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.unspecced.getActorProfile", 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.unspecced.getActorProfile", 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.unspecced.getActorProfile", 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.unspecced.getActorProfile", ownProfile()) show(did = harness.did, onBack = null, onOpenSettings = {}) composeRule.awaitContentDescription("Favorites") composeRule.awaitContentDescription("Stories") } @Test fun aGridWithNothingInItSaysWhichTabIsEmpty() { harness.server.on("dev.hatk.getFeed", """{"items":[]}""") show() composeRule.awaitText("No galleries yet") } @Test fun tappingATileOpensThatGallery() { var opened: String? = null show(onOpenGallery = { opened = it }) composeRule.onNodeWithContentDescription("Multiple photos").performClick() assertEquals("at://did:plc:alice/social.grain.gallery/gallery1", opened) } @Test fun theBackButtonIsThereOnSomebodyElsesProfile() { var back = 0 show(onBack = { back += 1 }) composeRule.onNodeWithContentDescription("Back").performClick() assertEquals(1, back) } }