package social.grain import android.net.Uri import androidx.camera.core.SurfaceRequest import androidx.compose.ui.test.assertIsNotEnabled 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.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.ReportSubject import social.grain.data.local.ProfileInvalidation import social.grain.data.location.LocationServices import social.grain.data.upload.GalleryDraftStore import social.grain.data.upload.GalleryUploadCenter import social.grain.feature.create.CreateGalleryScreen import social.grain.feature.create.CreateGalleryViewModel import social.grain.feature.moderation.ReportSheet import social.grain.feature.moderation.ReportViewModel import social.grain.feature.settings.EditProfileScreen import social.grain.feature.settings.EditProfileViewModel import social.grain.feature.stories.CameraBackend import social.grain.feature.stories.Lens import social.grain.feature.stories.OpenedCamera import social.grain.feature.stories.StoryCamera import social.grain.feature.stories.StoryCreateScreen import social.grain.feature.stories.StoryCreateViewModel import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.appContext import social.grain.support.awaitText import social.grain.support.json import social.grain.support.scrollTo import social.grain.support.type import social.grain.support.testJson import social.grain.ui.theme.GrainTheme /** * The three screens that write something, plus the report sheet. * * All four are forms, and what a form gets wrong is which state the Post button * is in: enabled with nothing to post, or disabled with everything it needs. */ @RunWith(AndroidJUnit4::class) class ComposerScreenTest { @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() private fun locations() = LocationServices(OkHttpClient(), testJson) // MARK: - New gallery private lateinit var createModel: CreateGalleryViewModel private fun createGallery(onClose: () -> Unit = {}, onPublished: () -> Unit = {}) { createModel = CreateGalleryViewModel( client = harness.client, auth = harness.auth, uploadCenter = GalleryUploadCenter(appContext, GalleryDraftStore(appContext, testJson)), locationServices = locations(), context = appContext, ) composeRule.setContent { GrainTheme { CreateGalleryScreen( onClose = onClose, onPublished = onPublished, viewModel = createModel, ) } } composeRule.waitForIdle() } @Test fun theGalleryComposerOpensEmptyAndCannotPostYet() { createGallery() composeRule.awaitText("New gallery") composeRule.onNodeWithText("Title").assertExists() composeRule.onNodeWithText("Description").assertExists() assertFalse("Nothing to post yet", createModel.state.value.canPost) } @Test fun aTitleAloneIsStillNotEnoughToPost() { createGallery() composeRule.onNodeWithText("Title").performTextInput("A walk in the fog") composeRule.waitUntil(10_000) { createModel.state.value.title.isNotBlank() } assertFalse("A gallery with no photos is not postable", createModel.state.value.canPost) assertTrue(createModel.state.value.hasChanges) } @Test fun aPhotoAndATitleTogetherMakeAPostableGallery() { createGallery() createModel.addPhotos(listOf(Uri.parse("content://photos/1"))) createModel.titleState.type("A walk in the fog") composeRule.waitUntil(10_000) { createModel.state.value.canPost } composeRule.awaitText("Post") } @Test fun closingADraftWithWorkInItAsksBeforeThrowingItAway() { var closed = 0 createGallery(onClose = { closed += 1 }) createModel.addPhotos(listOf(Uri.parse("content://photos/1"))) composeRule.waitUntil(10_000) { createModel.state.value.hasChanges } composeRule.onNodeWithContentDescription("Close").performClick() composeRule.awaitText("Discard gallery?") composeRule.onNodeWithText("Your photos and text won't be saved.").assertExists() composeRule.onNodeWithText("Keep editing").performClick() composeRule.waitForIdle() assertEquals(0, closed) composeRule.onNodeWithContentDescription("Close").performClick() composeRule.awaitText("Discard gallery?") composeRule.onNodeWithText("Discard").performClick() composeRule.waitUntil(10_000) { closed == 1 } } @Test fun closingAnUntouchedDraftJustCloses() { var closed = 0 createGallery(onClose = { closed += 1 }) composeRule.onNodeWithContentDescription("Close").performClick() composeRule.waitUntil(10_000) { closed == 1 } } @Test fun theComposerOffersTheSelfLabelsAndTheExifSwitch() { createGallery() // A lazy list: the switch and the chips are below the fold and are not // composed until scrolled to. composeRule.scrollTo("Include camera data") composeRule.scrollTo("Content labels") composeRule.scrollTo("Nudity") composeRule.scrollTo("Adult") } @Test fun aLabelTapsOnAndOffAgain() { createGallery() composeRule.scrollTo("Nudity") composeRule.onNodeWithText("Nudity").performClick() composeRule.waitUntil(10_000) { "nudity" in createModel.state.value.labels } composeRule.onNodeWithText("Nudity").performClick() composeRule.waitUntil(10_000) { "nudity" !in createModel.state.value.labels } } // MARK: - New story private lateinit var storyModel: StoryCreateViewModel /** A device with no camera at all, which is what Robolectric is. */ private object NoCamera : CameraBackend { override suspend fun open( owner: LifecycleOwner, lens: Lens, onSurface: (SurfaceRequest) -> Unit, ): OpenedCamera? = null } private fun storyComposer( onClose: () -> Unit = {}, onPosted: () -> Unit = {}, access: Boolean = true, ) { storyModel = StoryCreateViewModel(appContext, harness.client, harness.auth, locations()) val camera = StoryCamera(NoCamera, requestAccess = { access }, captureDir = appContext.cacheDir) composeRule.setContent { GrainTheme { StoryCreateScreen( onClose = onClose, onPosted = onPosted, viewModel = storyModel, camera = camera, ) } } composeRule.waitForIdle() } @Test fun theStoryComposerOpensOnTheViewfinderWithTheShutterDisabled() { storyComposer() composeRule.awaitText("Camera unavailable") composeRule.onNodeWithContentDescription("Take photo").assertIsNotEnabled() composeRule.onNodeWithContentDescription("Choose from library").assertExists() composeRule.onNodeWithText("Post").assertDoesNotExist() assertFalse(storyModel.state.value.canPost) } @Test fun deniedCameraAccessOffersToAskAgain() { storyComposer(access = false) composeRule.awaitText("Camera access is off") composeRule.onNodeWithText("Allow camera").assertExists() composeRule.onNodeWithContentDescription("Choose from library").assertExists() } @Test fun aChosenPhotoOpensTheReviewStage() { storyComposer() storyModel.setPhoto(Uri.parse("content://photos/1")) composeRule.waitUntil(10_000) { storyModel.state.value.canPost } composeRule.awaitText("Post") composeRule.onNodeWithText("Add location").assertExists() composeRule.onNodeWithText("Content warning").assertExists() composeRule.onNodeWithContentDescription("Retake").assertExists() } @Test fun retakeGoesBackToTheViewfinder() { storyComposer() storyModel.setPhoto(Uri.parse("content://photos/1")) composeRule.awaitText("Post") composeRule.onNodeWithContentDescription("Retake").performClick() composeRule.waitUntil(10_000) { storyModel.state.value.photoUri == null } composeRule.onNodeWithText("Post").assertDoesNotExist() } @Test fun theContentWarningChipOpensTheSameSelfLabels() { storyComposer() storyModel.setPhoto(Uri.parse("content://photos/1")) composeRule.awaitText("Content warning") composeRule.onNodeWithText("Content warning").performClick() composeRule.awaitText("Nudity") composeRule.onNodeWithText("Nudity").performClick() composeRule.waitUntil(10_000) { "nudity" in storyModel.state.value.labels } } @Test fun aSetLabelShowsOnItsChipAndClears() { storyComposer() storyModel.setPhoto(Uri.parse("content://photos/1")) storyModel.toggleLabel("nudity") storyModel.toggleLabel("porn") composeRule.awaitText("Nudity, Adult") composeRule.onNodeWithContentDescription("Clear Nudity, Adult").performClick() composeRule.waitUntil(10_000) { storyModel.state.value.labels.isEmpty() } composeRule.awaitText("Content warning") } @Test fun theLocationChipOpensThePicker() { storyComposer() storyModel.setPhoto(Uri.parse("content://photos/1")) composeRule.awaitText("Add location") composeRule.onNodeWithText("Add location").performClick() composeRule.awaitText("Search for a place") } @Test fun closingTheStoryComposerReportsIt() { var closed = 0 storyComposer(onClose = { closed += 1 }) composeRule.onNodeWithContentDescription("Close").performClick() composeRule.waitUntil(10_000) { closed == 1 } } // MARK: - Edit profile private lateinit var editModel: EditProfileViewModel private fun editProfile(onBack: () -> Unit = {}) { editModel = EditProfileViewModel( appContext, harness.client, harness.auth, ProfileInvalidation(), ) composeRule.setContent { GrainTheme { EditProfileScreen(onBack = onBack, viewModel = editModel) } } composeRule.waitForIdle() } @Test fun theProfileEditorFillsInWhatIsAlreadyPublished() { harness.server .on( "social.grain.unspecced.getActorProfile", json( Fixtures.profileDetailed( did = harness.did, handle = harness.handle, displayName = "Me", ), ), ) .on("dev.hatk.getRecord", """{"uri":"at://x","record":{"value":{}}}""") editProfile() composeRule.awaitText("Edit profile") composeRule.awaitText("Me") composeRule.onNodeWithText("Photographs of things").assertExists() composeRule.onNodeWithText("Display name").assertExists() composeRule.onNodeWithText("Bio").assertExists() } @Test fun theEditorOffersToChangeOrRemoveTheAvatar() { harness.server .on( "social.grain.unspecced.getActorProfile", json(Fixtures.profileDetailed(did = harness.did, handle = harness.handle)), ) .on("dev.hatk.getRecord", """{"uri":"at://x","record":{"value":{}}}""") editProfile() composeRule.awaitText("Change photo") composeRule.awaitText("Remove") composeRule.onNodeWithText("Remove").performClick() composeRule.waitUntil(10_000) { editModel.state.value.removeAvatar } } @Test fun theEditorsBackButtonReportsItsTap() { harness.server .on( "social.grain.unspecced.getActorProfile", json(Fixtures.profileDetailed(did = harness.did, handle = harness.handle)), ) .on("dev.hatk.getRecord", """{"uri":"at://x","record":{"value":{}}}""") var back = 0 editProfile(onBack = { back += 1 }) composeRule.awaitText("Edit profile") composeRule.onNodeWithContentDescription("Back").performClick() composeRule.waitUntil(10_000) { back == 1 } } // MARK: - Report sheet private fun reportSheet(subject: ReportSubject, onDismiss: () -> Unit = {}) { val model = ReportViewModel(harness.client, harness.auth) composeRule.setContent { GrainTheme { ReportSheet(subject = subject, onDismiss = onDismiss, viewModel = model) } } composeRule.waitForIdle() } @Test fun reportingContentListsTheCategoriesTheServerPublishes() { harness.server.on( "dev.hatk.describeLabels", """{"definitions":[{"identifier":"spam","locales":[{"name":"Spam"}]},""" + """{"identifier":"porn","locales":[{"name":"Adult Content"}]}]}""", ) reportSheet(ReportSubject.Record("at://x/gallery/1", "cid1")) composeRule.awaitText("Report content") composeRule.awaitText("Spam") composeRule.onNodeWithText("Adult Content").assertExists() composeRule.onNodeWithText("Details (optional)").assertExists() } @Test fun reportingAnAccountIsTitledDifferently() { harness.server.on( "dev.hatk.describeLabels", """{"definitions":[{"identifier":"spam","locales":[{"name":"Spam"}]}]}""", ) reportSheet(ReportSubject.Account("did:plc:bob")) composeRule.awaitText("Report account") } @Test fun submittingSendsTheReportAndConfirmsIt() { harness.server .on( "dev.hatk.describeLabels", """{"definitions":[{"identifier":"spam","locales":[{"name":"Spam"}]}]}""", ) .on("dev.hatk.createReport", """{"id":1}""") reportSheet(ReportSubject.Record("at://x/gallery/1", "cid1")) composeRule.awaitText("Spam") composeRule.onNodeWithText("Submit").performClick() composeRule.awaitText("Report submitted") composeRule.onNodeWithText("Done").assertExists() } @Test fun cancellingTheReportSheetSendsNothing() { harness.server.on( "dev.hatk.describeLabels", """{"definitions":[{"identifier":"spam","locales":[{"name":"Spam"}]}]}""", ) var dismissed = 0 reportSheet(ReportSubject.Record("at://x/gallery/1", "cid1"), onDismiss = { dismissed += 1 }) composeRule.awaitText("Spam") composeRule.onNodeWithText("Cancel").performClick() composeRule.waitUntil(10_000) { dismissed == 1 } assertEquals(0, harness.server.countOf("dev.hatk.createReport")) } }