package social.grain import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.Text 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 kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put 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.PlaceResult import social.grain.data.model.Facet import social.grain.feature.gallery.GalleryCommentSheet import social.grain.feature.gallery.GalleryCommentsViewModel import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.awaitText import social.grain.support.json import social.grain.ui.components.LocationPicker import social.grain.ui.components.RichText import social.grain.ui.theme.GrainTheme /** * The shared pieces screens are built out of. * * Each is used from several places, so a regression here is a regression * everywhere at once — and none of them owns any state a view model test could * reach. */ @RunWith(AndroidJUnit4::class) class ComponentsTest { @get:Rule val mainDispatcher = MainDispatcherRule() @get:Rule val composeRule = createComposeRule() private lateinit var harness: Harness @Before fun setUp() { harness = Harness() } @After fun tearDown() = harness.tearDown() // MARK: - RichText private fun facet(start: Int, end: Int, type: String, key: String, value: String) = Facet( index = Facet.ByteSlice(start, end), features = listOf( buildJsonObject { put("\$type", type) put(key, value) }, ), ) @Test fun plainTextWithNoFacetsIsJustText() { composeRule.setContent { GrainTheme { RichText(text = "Six frames from a grey morning") } } composeRule.awaitText("Six frames from a grey morning") } @Test fun aHashtagFacetIsTappableAndReportsItsTag() { var tapped: String? = null composeRule.setContent { GrainTheme { RichText( text = "Shot in #fog", facets = listOf( facet(9, 13, "app.bsky.richtext.facet#tag", "tag", "fog"), ), onHashtagTap = { tapped = it }, ) } } composeRule.awaitText("Shot in #fog") composeRule.onNodeWithText("Shot in #fog").performClick() } @Test fun aMentionFacetRendersAlongsideTheTextItAnnotates() { composeRule.setContent { GrainTheme { RichText( text = "With @alice.test", facets = listOf( facet(5, 16, "app.bsky.richtext.facet#mention", "did", "did:plc:alice"), ), onMentionTap = {}, ) } } composeRule.awaitText("With @alice.test") } @Test fun aLinkFacetRendersWithoutNeedingAHandler() { composeRule.setContent { GrainTheme { RichText( text = "See https://grain.social", facets = listOf( facet(4, 24, "app.bsky.richtext.facet#link", "uri", "https://grain.social"), ), ) } } composeRule.awaitText("See https://grain.social") } @Test fun aFacetTypeThisBuildDoesNotKnowIsDrawnAsPlainText() { composeRule.setContent { GrainTheme { RichText( text = "Something new", facets = listOf( facet(0, 9, "app.bsky.richtext.facet#somethingElse", "x", "y"), ), ) } } composeRule.awaitText("Something new") } // MARK: - LocationPicker private fun place(name: String, context: String? = "San Francisco") = PlaceResult( name = name, context = context, latitude = 37.76, longitude = -122.41, address = null, ) @Test fun thePickerInvitesASearchWhenNothingIsChosen() { composeRule.setContent { GrainTheme { LocationPicker( queryState = TextFieldState(), results = emptyList(), isSearching = false, selected = null, suggestion = null, onSelect = {}, onClear = {}, ) } } composeRule.awaitText("Location") composeRule.onNodeWithText("Search for a place").assertExists() } @Test fun resultsAreListedAndReportTheOneTapped() { var chosen: PlaceResult? = null composeRule.setContent { GrainTheme { LocationPicker( queryState = TextFieldState().apply { setTextAndPlaceCursorAtEnd("mission") }, results = listOf(place("Mission District"), place("Mission Bay")), isSearching = false, selected = null, suggestion = null, onSelect = { chosen = it }, onClear = {}, ) } } composeRule.awaitText("Mission District") composeRule.onNodeWithText("Mission Bay").assertExists() composeRule.onNodeWithText("Mission District").performClick() assertEquals("Mission District", chosen?.name) } @Test fun aPlaceReadOffThePhotoIsOfferedRatherThanApplied() { var chosen: PlaceResult? = null composeRule.setContent { GrainTheme { LocationPicker( queryState = TextFieldState(), results = emptyList(), isSearching = false, selected = null, suggestion = place("Ocean Beach"), onSelect = { chosen = it }, onClear = {}, ) } } composeRule.awaitText("From this photo") composeRule.onNodeWithText("Ocean Beach").performClick() assertEquals("Ocean Beach", chosen?.name) } @Test fun aChosenPlaceIsShownWithAWayToRemoveIt() { var cleared = 0 composeRule.setContent { GrainTheme { LocationPicker( queryState = TextFieldState(), results = emptyList(), isSearching = false, selected = place("Mission District"), suggestion = null, onSelect = {}, onClear = { cleared += 1 }, ) } } composeRule.awaitText("Mission District") composeRule.onNodeWithContentDescription("Remove location").performClick() assertEquals(1, cleared) } // MARK: - The gallery comment sheet @Test fun theGallerySheetOpensOnItsGalleryAndReportsTheCountBack() { harness.server.on( "social.grain.unspecced.getCommentThread", """{"comments":[${json(Fixtures.comment(text = "Lovely light"))}],"totalCount":4}""", ) var count = -1 val model = GalleryCommentsViewModel(harness.client, harness.auth) composeRule.setContent { GrainTheme { GalleryCommentSheet( galleryUri = "at://did:plc:alice/social.grain.gallery/gallery1", onDismiss = {}, onProfileTap = {}, onCommentCountChanged = { count = it }, viewModel = model, ) } } composeRule.awaitText("Lovely light") composeRule.waitUntil(10_000) { count == 4 } } @Test fun tappingAnAuthorInTheGallerySheetClosesItFirst() { harness.server.on( "social.grain.unspecced.getCommentThread", """{"comments":[${json(Fixtures.comment())}],"totalCount":1}""", ) var dismissed = 0 var opened: String? = null val model = GalleryCommentsViewModel(harness.client, harness.auth) composeRule.setContent { GrainTheme { GalleryCommentSheet( galleryUri = "at://did:plc:alice/social.grain.gallery/gallery1", onDismiss = { dismissed += 1 }, onProfileTap = { opened = it }, viewModel = model, ) } } composeRule.awaitText("Lovely light") composeRule.onNodeWithContentDescription("alice.grain.social's avatar").performClick() composeRule.waitUntil(10_000) { opened == "did:plc:alice" && dismissed == 1 } } }