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 } }