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.performClick import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import okhttp3.OkHttpClient 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.location.LocationServices import social.grain.feature.actors.ActorListMode import social.grain.feature.actors.ActorListScreen import social.grain.feature.actors.ActorListViewModel import social.grain.feature.feed.HashtagFeedScreen import social.grain.feature.feed.HashtagFeedViewModel import social.grain.feature.feed.LocationFeedScreen import social.grain.feature.feed.LocationFeedViewModel import social.grain.feature.gallery.GalleryDetailScreen import social.grain.feature.gallery.GalleryDetailViewModel import social.grain.feature.settings.ModerationMode import social.grain.feature.settings.ModerationScreen import social.grain.feature.settings.ModerationViewModel 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.support.testJson import social.grain.ui.theme.GrainTheme /** * The screens that are a list, a title bar and an empty state. * * Grouped because that is genuinely all they are: each wraps one view model * already tested on its own, and what is worth pinning here is the shell — the * right title, the right words when there is nothing to show, and a back button * that reports its tap. */ @RunWith(AndroidJUnit4::class) class ScreenShellTest { @get:Rule val mainDispatcher = MainDispatcherRule() @get:Rule val composeRule = createComposeRule() private lateinit var harness: Harness @Before fun setUp() { harness = Harness() harness.server .on("dev.hatk.describeLabels", """{"definitions":[]}""") .on("dev.hatk.getFeed", """{"items":[${json(Fixtures.gallery())}]}""") } @After fun tearDown() = harness.tearDown() // MARK: - Actor lists private fun actorList( mode: ActorListMode, subject: String = "did:plc:alice", onBack: () -> Unit = {}, onOpenProfile: (String) -> Unit = {}, ) { val model = ActorListViewModel( harness.client, harness.auth, SavedStateHandle(mapOf("mode" to mode, "subject" to subject)), ) composeRule.setContent { GrainTheme { ActorListScreen(onBack = onBack, onOpenProfile = onOpenProfile, viewModel = model) } } composeRule.waitForIdle() } @Test fun aFollowerListIsTitledAndListsWhoItWasGiven() { harness.server.on( "social.grain.unspecced.getFollowers", """{"totalCount":1,"items":[{"did":"did:plc:bob","handle":"bob.test","displayName":"Bob"}]}""", ) var opened: String? = null actorList(ActorListMode.FOLLOWERS, onOpenProfile = { opened = it }) composeRule.awaitText("Followers") composeRule.awaitText("Bob") composeRule.onNodeWithText("Bob").performClick() assertEquals("did:plc:bob", opened) } @Test fun anEmptyFollowerListSaysWhoItIsAboutRatherThanNothing() { harness.server.on("social.grain.unspecced.getFollowers", """{"items":[]}""") actorList(ActorListMode.FOLLOWERS) composeRule.awaitText("No followers") composeRule.awaitText("No one is following this account yet.") } @Test fun anEmptyFavoritesListHasItsOwnWording() { harness.server.on("social.grain.unspecced.getGalleryFavorites", """{"items":[]}""") actorList(ActorListMode.GALLERY_FAVORITES, subject = "at://x/gallery/1") composeRule.awaitText("Favorited by") composeRule.awaitText("No one has favorited this gallery yet.") } @Test fun theActorListBackButtonReportsItsTap() { harness.server.on("social.grain.unspecced.getFollowing", """{"items":[]}""") var back = 0 actorList(ActorListMode.FOLLOWING, onBack = { back += 1 }) composeRule.onNodeWithContentDescription("Back").performClick() assertEquals(1, back) } // MARK: - Moderation lists private fun moderation(mode: ModerationMode, onBack: () -> Unit = {}) { val model = ModerationViewModel( harness.client, harness.auth, SavedStateHandle(mapOf("mode" to mode)), ) composeRule.setContent { GrainTheme { ModerationScreen(onBack = onBack, onOpenProfile = {}, viewModel = model) } } composeRule.waitForIdle() } @Test fun theBlockedListNamesWhoIsBlockedAndOffersToUndoIt() { harness.server.on( "social.grain.unspecced.getBlocks", """{"items":[{"did":"did:plc:bob","handle":"bob.test","displayName":"Bob",""" + """"blockUri":"at://did:plc:me/social.grain.graph.block/b1"}]}""", ) moderation(ModerationMode.BLOCKED) composeRule.awaitText("Blocked users") composeRule.awaitText("Bob") composeRule.onNodeWithText("Unblock").performClick() composeRule.waitUntil(10_000) { harness.server.countOf("dev.hatk.deleteRecord") >= 1 } } @Test fun anEmptyMutedListSaysSo() { harness.server.on("social.grain.unspecced.getMutes", """{"items":[]}""") var back = 0 moderation(ModerationMode.MUTED, onBack = { back += 1 }) composeRule.awaitText("Muted users") composeRule.awaitText("You haven't muted anyone.") composeRule.onNodeWithContentDescription("Back").performClick() assertEquals(1, back) } // MARK: - Gallery detail private fun galleryDetail(onBack: () -> Unit = {}) { val model = GalleryDetailViewModel( harness.client, harness.auth, harness.labelDefinitions, SavedStateHandle(mapOf("uri" to "at://did:plc:alice/social.grain.gallery/gallery1")), ) composeRule.setContent { GrainTheme { GalleryDetailScreen(onBack = onBack, onOpenProfile = {}, viewModel = model) } } composeRule.waitForIdle() } @Test fun theGalleryScreenDrawsTheCardAndItsCommentPrompt() { harness.server .on( "social.grain.unspecced.getGallery", """{"gallery":${json(Fixtures.gallery())}}""", ) .on( "social.grain.unspecced.getCommentThread", """{"comments":[${json(Fixtures.comment())}],"totalCount":3}""", ) galleryDetail() composeRule.awaitText("Gallery") composeRule.awaitText("A walk in the fog") composeRule.awaitText("View all 3 comments") } @Test fun aGalleryWithNoCommentsInvitesTheFirstOne() { harness.server .on( "social.grain.unspecced.getGallery", """{"gallery":${json(Fixtures.gallery(commentCount = 0))}}""", ) .on("social.grain.unspecced.getCommentThread", """{"comments":[],"totalCount":0}""") galleryDetail() composeRule.awaitText("Add a comment…") } @Test fun aGalleryThatIsGoneSaysSoRatherThanSpinningForever() { harness.server .on("social.grain.unspecced.getGallery", """{"error":"gone"}""", code = 404) .on("social.grain.unspecced.getCommentThread", """{"comments":[]}""") var back = 0 galleryDetail(onBack = { back += 1 }) composeRule.awaitText("Gallery unavailable") composeRule.onNodeWithContentDescription("Back").performClick() assertEquals(1, back) } @Test fun theViewersOwnGalleryOffersToDeleteIt() { harness.server .on( "social.grain.unspecced.getGallery", """{"gallery":${json( Fixtures.gallery( creator = Fixtures.profile(did = "did:plc:me", handle = "me.test"), ), )}}""", ) .on("social.grain.unspecced.getCommentThread", """{"comments":[]}""") galleryDetail() composeRule.onNodeWithContentDescription("Delete gallery").performClick() composeRule.awaitText("Delete gallery?") composeRule.onNodeWithText("Cancel").performClick() composeRule.waitForIdle() assertEquals(0, harness.server.countOf("social.grain.unspecced.deleteGallery")) } // MARK: - Tapped-through feeds @Test fun aHashtagFeedIsTitledAfterItsTagAndListsWhatItFound() { var back = 0 val model = HashtagFeedViewModel( harness.client, harness.auth, harness.labelDefinitions, SavedStateHandle(mapOf("tag" to "fog")), ) composeRule.setContent { GrainTheme { HashtagFeedScreen( onBack = { back += 1 }, onOpenProfile = {}, onOpenHashtag = {}, viewModel = model, ) } } composeRule.waitForIdle() composeRule.awaitText("#fog") composeRule.awaitText("A walk in the fog") composeRule.onNodeWithContentDescription("Back").performClick() assertEquals(1, back) } @Test fun anEmptyHashtagFeedNamesTheTagThatFoundNothing() { harness.server.on("dev.hatk.getFeed", """{"items":[]}""") val model = HashtagFeedViewModel( harness.client, harness.auth, harness.labelDefinitions, SavedStateHandle(mapOf("tag" to "quiet")), ) composeRule.setContent { GrainTheme { HashtagFeedScreen( onBack = {}, onOpenProfile = {}, onOpenHashtag = {}, viewModel = model, ) } } composeRule.waitForIdle() composeRule.awaitText("Nothing tagged #quiet yet") } @Test fun aLocationFeedIsTitledWithThePlaceNameTheReaderTapped() { val model = LocationFeedViewModel( harness.client, harness.auth, LocationServices(OkHttpClient(), testJson), harness.labelDefinitions, SavedStateHandle(mapOf("cell" to "8928308280fffff", "name" to "Mission District")), ) composeRule.setContent { GrainTheme { LocationFeedScreen( onBack = {}, onOpenProfile = {}, onOpenHashtag = {}, viewModel = model, ) } } composeRule.waitForIdle() composeRule.awaitText("Mission District") composeRule.awaitText("A walk in the fog") } @Test fun anEmptyLocationFeedNamesThePlaceThatFoundNothing() { harness.server.on("dev.hatk.getFeed", """{"items":[]}""") val model = LocationFeedViewModel( harness.client, harness.auth, LocationServices(OkHttpClient(), testJson), harness.labelDefinitions, SavedStateHandle(mapOf("cell" to "89283", "name" to "Nowhere")), ) composeRule.setContent { GrainTheme { LocationFeedScreen( onBack = {}, onOpenProfile = {}, onOpenHashtag = {}, viewModel = model, ) } } composeRule.waitForIdle() composeRule.awaitText("Nothing from Nowhere yet") } }