~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
437 lines · 14.7 KB Raw History
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.actor.getProfile",
                json(
                    Fixtures.profileDetailed(
                        did = harness.did,
                        handle = harness.handle,
                        displayName = "Me",
                    ),
                ),
            )
            .on("com.atproto.repo.getRecord", """{"uri":"at://x","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.actor.getProfile",
                json(Fixtures.profileDetailed(did = harness.did, handle = harness.handle)),
            )
            .on("com.atproto.repo.getRecord", """{"uri":"at://x","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.actor.getProfile",
                json(Fixtures.profileDetailed(did = harness.did, handle = harness.handle)),
            )
            .on("com.atproto.repo.getRecord", """{"uri":"at://x","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, harness.labelDefinitions)
        composeRule.setContent {
            GrainTheme { ReportSheet(subject = subject, onDismiss = onDismiss, viewModel = model) }
        }
        composeRule.waitForIdle()
    }

    @Test
    fun reportingContentListsGrainsLabels() {
        reportSheet(ReportSubject.Record("at://x/gallery/1", "cid1"))

        composeRule.awaitText("Report content")
        composeRule.awaitText("Copyright Violation")
        composeRule.onNodeWithText("Graphic Media").assertExists()
        composeRule.onNodeWithText("Details (optional)").assertExists()
    }

    @Test
    fun reportingAnAccountIsTitledDifferently() {
        reportSheet(ReportSubject.Account("did:plc:bob"))

        composeRule.awaitText("Report account")
    }

    @Test
    fun submittingSendsTheReportAndConfirmsIt() {
        harness.server.on("com.atproto.moderation.createReport", """{"id":1}""")
        reportSheet(ReportSubject.Record("at://x/gallery/1", "cid1"))
        composeRule.awaitText("Copyright Violation")

        composeRule.onNodeWithText("Submit").performScrollTo().performClick()

        composeRule.awaitText("Report submitted")
        composeRule.onNodeWithText("Done").assertExists()
    }

    @Test
    fun cancellingTheReportSheetSendsNothing() {
        var dismissed = 0
        reportSheet(ReportSubject.Record("at://x/gallery/1", "cid1"), onDismiss = { dismissed += 1 })
        composeRule.awaitText("Copyright Violation")

        composeRule.onNodeWithText("Cancel").performScrollTo().performClick()

        composeRule.waitUntil(10_000) { dismissed == 1 }
        assertEquals(0, harness.server.countOf("com.atproto.moderation.createReport"))
    }
}