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

grain.social/grain-android

1 collaborator
0
1
main
204 lines · 7.6 KB Raw History
package social.grain

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.TouchInjectionScope
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.model.AspectRatio
import social.grain.data.model.GrainGallery
import social.grain.data.model.GrainPhoto
import social.grain.data.model.GrainProfile
import social.grain.ui.components.GalleryCard
import social.grain.ui.components.ImageZoomHost
import social.grain.ui.components.ImageZoomState
import social.grain.ui.components.LocalImageZoom
import social.grain.ui.components.ZoomablePhoto
import social.grain.ui.theme.GrainTheme

/**
 * Pinch-to-zoom on a feed photo.
 *
 * Worth testing at this level because what breaks the gesture isn't the maths —
 * it's the two scrolling containers wrapped around every photo in the app. A
 * pager and a lazy list both read finger movement as their own, and whether the
 * pinch survives them can only be answered by injecting real pointers through
 * the real card.
 *
 * Each test asserts with the fingers still down, then lifts them in a second
 * call. Running the pinch to completion first would race the spring back to
 * rest, which is the state the assertions are trying to see past.
 *
 * Fixtures are local to the file, as in the previews. The photos never load,
 * which doesn't matter: the frame comes from the aspect ratio, and the gesture
 * lives on the frame.
 */
@RunWith(AndroidJUnit4::class)
class PhotoPinchZoomTest {

    @get:Rule
    val composeRule = createComposeRule()

    private fun photo(alt: String? = null) = GrainPhoto(
        uri = "at://did:plc:test/social.grain.photo/$alt",
        thumb = "",
        fullsize = "",
        alt = alt,
        aspectRatio = AspectRatio(3, 2),
    )

    /** The second photo has no description on purpose: the ALT badge is drawn
     *  from whichever photo is on screen, so its disappearance is how a page
     *  change is observable from the outside. */
    private val gallery = GrainGallery(
        uri = "at://did:plc:test/social.grain.gallery/1",
        title = "Pinch me",
        creator = GrainProfile(did = "did:plc:test", handle = "test.grain.social"),
        items = listOf(photo("A granite face under low cloud."), photo(), photo("Third.")),
        indexedAt = "2026-01-04T09:12:00Z",
    )

    /** Hands the host's zoom state back out so a test can read the scale. */
    @Composable
    private fun CaptureZoom(onState: (ImageZoomState) -> Unit) {
        val state = LocalImageZoom.current
        SideEffect { state?.let(onState) }
    }

    @Composable
    private fun Feed(onState: (ImageZoomState) -> Unit) {
        GrainTheme(dynamicColor = false) {
            ImageZoomHost(Modifier.fillMaxSize()) {
                CaptureZoom(onState)
                // Both scrolling parents present, as in the real feed.
                LazyColumn(Modifier.fillMaxSize().testTag(FEED)) {
                    item { GalleryCard(gallery = gallery) }
                }
            }
        }
    }

    @Test
    fun pinchingAPhotoZoomsItAndThenLetsItGo() {
        lateinit var zoom: ImageZoomState

        composeRule.setContent {
            GrainTheme(dynamicColor = false) {
                ImageZoomHost(Modifier.fillMaxSize()) {
                    CaptureZoom { zoom = it }
                    ZoomablePhoto(
                        model = "",
                        contentDescription = "A photo",
                        modifier = Modifier.size(300.dp).testTag(PHOTO),
                    )
                }
            }
        }

        composeRule.onNodeWithTag(PHOTO).performTouchInput { spreadFingers(center) }
        composeRule.waitForIdle()

        assertNotNull("The pinch never claimed the photo", zoom.source)
        assertTrue("The pinch didn't scale the photo, got ${zoom.scale}", zoom.scale > 1f)

        composeRule.onNodeWithTag(PHOTO).performTouchInput { liftFingers() }

        composeRule.waitUntil(REST_TIMEOUT_MS) { zoom.source == null }
        assertEquals("Didn't spring back to rest", 1f, zoom.scale, 0.001f)
        assertEquals("Pan didn't spring back to rest", Offset.Zero, zoom.pan)
    }

    @Test
    fun pinchingInsideACarouselBeatsThePagerAndTheFeedToIt() {
        lateinit var zoom: ImageZoomState
        composeRule.setContent { Feed { zoom = it } }
        composeRule.waitForIdle()

        composeRule.onNodeWithTag(FEED).performTouchInput { spreadFingers(overCarousel()) }
        composeRule.waitForIdle()

        assertTrue(
            "The pager or the feed swallowed the pinch, scale ${zoom.scale}",
            zoom.scale > 1f,
        )

        composeRule.onNodeWithTag(FEED).performTouchInput { liftFingers() }
        composeRule.waitUntil(REST_TIMEOUT_MS) { zoom.source == null }
    }

    @Test
    fun oneFingerStillSwipesTheCarousel() {
        lateinit var zoom: ImageZoomState
        composeRule.setContent { Feed { zoom = it } }
        composeRule.waitForIdle()

        composeRule.onNodeWithContentDescription(ALT_BADGE).assertExists()

        composeRule.onNodeWithTag(FEED).performTouchInput {
            val y = overCarousel().y
            swipe(Offset(width * 0.9f, y), Offset(width * 0.1f, y), durationMillis = 200)
        }
        composeRule.waitForIdle()

        assertNull("A one-finger drag started a zoom", zoom.source)
        // Page two has no description, so the badge is gone — which it can only
        // be if the swipe reached the pager.
        composeRule.onNodeWithContentDescription(ALT_BADGE).assertDoesNotExist()
    }

    /**
     * Roughly the middle of the first card's photo.
     *
     * Measured as a fraction rather than found in the tree because the carousel
     * isn't a semantics node of its own. The card header is about 54dp tall and
     * a 3:2 photo fills the width, so on any phone-shaped screen the top fifth
     * of the feed lands inside the frame.
     */
    private fun TouchInjectionScope.overCarousel() = Offset(width / 2f, height * 0.22f)

    /** Two fingers down together, then drawn apart — a pinch out to about 5x. */
    private fun TouchInjectionScope.spreadFingers(at: Offset) {
        down(0, at - Offset(FINGER_GAP, 0f))
        down(1, at + Offset(FINGER_GAP, 0f))
        repeat(5) {
            // Both pointers updated before a single move event, so the fingers
            // travel together the way real ones do. Moving them one at a time
            // would read as a pan followed by a stretch.
            updatePointerBy(0, Offset(-FINGER_STEP, 0f))
            updatePointerBy(1, Offset(FINGER_STEP, 0f))
            move()
        }
    }

    private fun TouchInjectionScope.liftFingers() {
        up(0)
        up(1)
    }

    private companion object {
        const val PHOTO = "zoomable-photo"
        const val FEED = "feed"
        const val ALT_BADGE = "Show alt text"
        const val REST_TIMEOUT_MS = 3_000L
        const val FINGER_GAP = 30f
        const val FINGER_STEP = 24f
    }
}