1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
}
}