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

grain.social/grain-android

1 collaborator
0
1
main
297 lines · 9.1 KB Raw History
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.comment.getThread",
            """{"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.comment.getThread",
            """{"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 }
    }
}