Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
7.8 kB · 204 lines
Kotlin
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205package social.grain
import androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.sizeimport androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.runtime.Composableimport androidx.compose.runtime.SideEffectimport androidx.compose.ui.Modifierimport androidx.compose.ui.geometry.Offsetimport androidx.compose.ui.platform.testTagimport androidx.compose.ui.test.TouchInjectionScopeimport androidx.compose.ui.test.junit4.v2.createComposeRuleimport androidx.compose.ui.test.onNodeWithContentDescriptionimport androidx.compose.ui.test.onNodeWithTagimport androidx.compose.ui.test.performTouchInputimport androidx.compose.ui.test.swipeimport androidx.compose.ui.unit.dpimport androidx.test.ext.junit.runners.AndroidJUnit4import org.junit.Assert.assertEqualsimport org.junit.Assert.assertNotNullimport org.junit.Assert.assertNullimport org.junit.Assert.assertTrueimport org.junit.Ruleimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.model.AspectRatioimport social.grain.data.model.GrainGalleryimport social.grain.data.model.GrainPhotoimport social.grain.data.model.GrainProfileimport social.grain.ui.components.GalleryCardimport social.grain.ui.components.ImageZoomHostimport social.grain.ui.components.ImageZoomStateimport social.grain.ui.components.LocalImageZoomimport social.grain.ui.components.ZoomablePhotoimport 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 }}