Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456package social.grain
import android.net.Uriimport androidx.camera.core.SurfaceRequestimport androidx.compose.ui.test.assertIsNotEnabledimport androidx.compose.ui.test.junit4.v2.createComposeRuleimport androidx.compose.ui.test.onNodeWithContentDescriptionimport androidx.compose.ui.test.onNodeWithTextimport androidx.compose.ui.test.performClickimport androidx.compose.ui.test.performScrollToimport androidx.compose.ui.test.performTextInputimport androidx.lifecycle.LifecycleOwnerimport androidx.test.ext.junit.runners.AndroidJUnit4import okhttp3.OkHttpClientimport org.junit.Afterimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertFalseimport org.junit.Assert.assertTrueimport org.junit.Beforeimport org.junit.Ruleimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.api.ReportSubjectimport social.grain.data.local.ProfileInvalidationimport social.grain.data.location.LocationServicesimport social.grain.data.upload.GalleryDraftStoreimport social.grain.data.upload.GalleryUploadCenterimport social.grain.feature.create.CreateGalleryScreenimport social.grain.feature.create.CreateGalleryViewModelimport social.grain.feature.moderation.ReportSheetimport social.grain.feature.moderation.ReportViewModelimport social.grain.feature.settings.EditProfileScreenimport social.grain.feature.settings.EditProfileViewModelimport social.grain.feature.stories.CameraBackendimport social.grain.feature.stories.Lensimport social.grain.feature.stories.OpenedCameraimport social.grain.feature.stories.StoryCameraimport social.grain.feature.stories.StoryCreateScreenimport social.grain.feature.stories.StoryCreateViewModelimport social.grain.support.Fixturesimport social.grain.support.Harnessimport social.grain.support.MainDispatcherRuleimport social.grain.support.appContextimport social.grain.support.awaitTextimport social.grain.support.jsonimport social.grain.support.scrollToimport social.grain.support.typeimport social.grain.support.testJsonimport 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")) }}