summaryrefslogtreecommitdiff
path: root/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt
blob: aead8856d024aafdfb02410122ee6468e87690fd (plain)
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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
package org.terst.feedreader.ui

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class ImageZoomTest {

    @Test
    fun `a bare image is wrapped in a zoom link pointing at its own source`() {
        val html = linkifyImagesForZoom("""<p>hi</p><img src="https://ex.com/a.jpg" alt="x">""")
        assertEquals(
            """<p>hi</p><a href="feedreader-image:https://ex.com/a.jpg"><img src="https://ex.com/a.jpg" alt="x"></a>""",
            html
        )
    }

    @Test
    fun `an image already inside a link is left alone`() {
        // The YouTube thumbnail ReaderExtractor synthesizes is exactly this shape -- wrapping it
        // again would swallow the tap that's supposed to play the video.
        val original =
            """<a href="https://www.youtube.com/watch?v=abcdefghijk"><img src="https://i.ytimg.com/vi/x/hq720.jpg"></a>"""
        assertEquals(original, linkifyImagesForZoom(original))
    }

    @Test
    fun `an image after a closed link is still wrapped`() {
        val html = linkifyImagesForZoom("""<a href="https://ex.com/">t</a><img src="https://ex.com/a.png">""")
        assertTrue(html.endsWith("""<a href="feedreader-image:https://ex.com/a.png"><img src="https://ex.com/a.png"></a>"""))
    }

    @Test
    fun `a protocol-relative image source becomes an https zoom target`() {
        val html = linkifyImagesForZoom("""<img src="//ex.com/a.jpg">""")
        assertEquals("""<a href="feedreader-image:https://ex.com/a.jpg"><img src="//ex.com/a.jpg"></a>""", html)
    }

    @Test
    fun `a relative image source is left alone when nothing says where it came from`() {
        // Relative srcs resolve against the WebView's base URL -- with no base URL to resolve
        // against, better to leave the image un-zoomable than to build a dead link.
        val original = """<img src="/local/a.jpg">"""
        assertEquals(original, linkifyImagesForZoom(original))
    }

    @Test
    fun `a site-relative image source resolves against the page it came from`() {
        // Verbatim shape of the Ftrain feed's own markup, which is where this surfaced:
        // every image in <content:encoded> is site-relative, so none of them were zoomable.
        val html = linkifyImagesForZoom(
            """<img src="/art/uploads/header.png" alt="x">""",
            baseUrl = "https://ftrain.com/aboard-let-s-have-a-mouse-party"
        )
        assertEquals(
            """<a href="feedreader-image:https://ftrain.com/art/uploads/header.png">""" +
                """<img src="/art/uploads/header.png" alt="x"></a>""",
            html
        )
    }

    @Test
    fun `a path-relative image source resolves against the article's own directory`() {
        val html = linkifyImagesForZoom(
            """<img src="pic.png">""",
            baseUrl = "https://ex.com/posts/2026/hello"
        )
        assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/posts/2026/pic.png">"""))
    }

    @Test
    fun `a data uri image is still left alone when there is a page url`() {
        // Resolving a data: src against a base URL just gives the data: URI back -- it is
        // not something the viewer could ever open, so it must not become a zoom target.
        val original = """<img src="data:image/png;base64,AAAA">"""
        assertEquals(original, linkifyImagesForZoom(original, baseUrl = "https://ex.com/post"))
    }

    @Test
    fun `a data uri image is left alone`() {
        val original = """<img src="data:image/png;base64,AAAA">"""
        assertEquals(original, linkifyImagesForZoom(original))
    }

    @Test
    fun `wrapping is not confused by tags that merely start with the letter a`() {
        val html = linkifyImagesForZoom("""<abbr title="x">y</abbr><img src="https://ex.com/a.jpg">""")
        assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/a.jpg">"""))
    }

    @Test
    fun `a zoom-scheme url resolves back to the image it wraps`() {
        assertEquals("https://ex.com/a.jpg", zoomableImageTarget("feedreader-image:https://ex.com/a.jpg"))
    }

    @Test
    fun `a plain link straight to an image file is a zoom target too`() {
        // WordPress's default "link to media file" wraps article images in exactly this --
        // the image is inside an <a>, so it never gets a zoom-scheme href of its own.
        assertEquals("https://ex.com/photo.JPEG?w=900", zoomableImageTarget("https://ex.com/photo.JPEG?w=900"))
    }

    @Test
    fun `an ordinary article link is not a zoom target`() {
        assertNull(zoomableImageTarget("https://ex.com/2026/09/some-article"))
    }

    @Test
    fun `an svg link is not a zoom target`() {
        // Coil's default decoders can't render SVG, so a viewer opened on one shows nothing.
        assertNull(zoomableImageTarget("https://ex.com/icon.svg"))
    }

    @Test
    fun `reader html makes an article's own images zoomable`() {
        val html = wrapReaderHtml("""<p>x</p><img src="https://ex.com/a.jpg">""")
        assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/a.jpg"><img src="https://ex.com/a.jpg"></a>"""))
    }

    @Test
    fun `reader html makes a feed article's relative images zoomable`() {
        // The non-reader path renders the feed's own HTML, which routinely keeps relative
        // srcs -- the reader path never hits this because Readability absolutizes them.
        val html = wrapReaderHtml(
            """<p>x</p><img src="/art/a.png">""",
            baseUrl = "https://ftrain.com/aboard-let-s-have-a-mouse-party"
        )
        assertTrue(html.contains("""<a href="feedreader-image:https://ftrain.com/art/a.png">"""))
    }

    @Test
    fun `reader html makes the synthesized hero image zoomable too`() {
        val html = wrapReaderHtml("<p>no image of its own</p>", imageUrl = "https://ex.com/hero.jpg")
        assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/hero.jpg">"""))
        assertTrue(html.contains("""class="article-hero""""))
    }

    @Test
    fun `reader html leaves a youtube thumbnail tapping through to the video`() {
        // The thumbnail is synthesized from an <iframe> during wrapping, after which it's an
        // image inside a link -- the zoom pass has to run late enough to see it as one.
        val html = wrapReaderHtml("""<iframe src="https://www.youtube.com/embed/abcdefghijk"></iframe>""")
        assertTrue(html.contains("""<a href="https://www.youtube.com/watch?v=abcdefghijk">"""))
        assertTrue(!html.contains("feedreader-image:https://i.ytimg.com"))
    }

    @Test
    fun `long-pressing a zoom-wrapped image offers the real image url, not the internal scheme`() {
        assertEquals("https://ex.com/a.jpg", withoutImageZoomScheme("feedreader-image:https://ex.com/a.jpg"))
        assertEquals("https://ex.com/page", withoutImageZoomScheme("https://ex.com/page"))
    }

    @Test
    fun `pinching out scales the image up`() {
        assertEquals(2f, imageZoomScale(1f, 2f), 0.001f)
    }

    @Test
    fun `the image never zooms out past its fitted size`() {
        assertEquals(1f, imageZoomScale(1.2f, 0.1f), 0.001f)
    }

    @Test
    fun `the image never zooms in past the maximum scale`() {
        assertEquals(IMAGE_ZOOM_MAX_SCALE, imageZoomScale(4f, 10f), 0.001f)
    }

    @Test
    fun `double-tapping an unzoomed image zooms it in`() {
        assertTrue(imageZoomDoubleTapScale(1f) > 1f)
    }

    @Test
    fun `double-tapping a zoomed image returns it to its fitted size`() {
        assertEquals(1f, imageZoomDoubleTapScale(imageZoomDoubleTapScale(1f)), 0.001f)
    }

    @Test
    fun `an unzoomed image cannot be panned`() {
        assertEquals(0f, clampImagePanOffset(400f, viewportSize = 1000f, contentSize = 1000f, scale = 1f), 0.001f)
    }

    @Test
    fun `a zoomed image pans up to the edge of its scaled self`() {
        // At 2x over a 1000px viewport the image is 2000px wide, so 500px of it hangs off
        // each side -- that's exactly how far it may be dragged before showing empty space.
        assertEquals(500f, clampImagePanOffset(9000f, 1000f, 1000f, 2f), 0.001f)
        assertEquals(-500f, clampImagePanOffset(-9000f, 1000f, 1000f, 2f), 0.001f)
        assertEquals(120f, clampImagePanOffset(120f, 1000f, 1000f, 2f), 0.001f)
    }

    @Test
    fun `panning is bounded by the image itself, not by the viewport it sits in`() {
        // A wide photo letterboxed into a square viewport: at 2x it's 1000x1000 again, so it
        // still fills the viewport exactly and there's nothing off-screen to pan to
        // vertically -- bounding by the viewport instead would let it be dragged into the
        // black bars above and below it.
        assertEquals(0f, clampImagePanOffset(400f, 1000f, 500f, 2f), 0.001f)
        // ...but at 4x it's 2000px tall over a 1000px viewport, so 500px hangs off each edge.
        assertEquals(500f, clampImagePanOffset(9000f, 1000f, 500f, 4f), 0.001f)
    }

    @Test
    fun `a wide image is fitted to the viewport's width, leaving bars above and below`() {
        assertEquals(1000f to 500f, fittedImageSize(1000f, 1000f, 2000f, 1000f))
    }

    @Test
    fun `a tall image is fitted to the viewport's height`() {
        assertEquals(500f to 1000f, fittedImageSize(1000f, 1000f, 1000f, 2000f))
    }

    @Test
    fun `a drag on a zoomed image pans it`() {
        // The regression this function exists for: the viewer computed its pan bounds from
        // values captured once at composition (viewport still zero, image not yet decoded),
        // which pinned every offset to 0 -- a zoomed image simply refused to pan.
        val panned = imageZoomTransform(
            ImageZoomState(scale = 2f),
            viewportSize = 1000f to 1000f,
            intrinsicSize = 1000f to 1000f,
            pan = -100f to -50f,
            gestureZoom = 1f
        )
        assertEquals(2f, panned.scale, 0.001f)
        assertEquals(-100f, panned.offsetX, 0.001f)
        assertEquals(-50f, panned.offsetY, 0.001f)
    }

    @Test
    fun `a drag on an unzoomed image leaves it where it is`() {
        val panned = imageZoomTransform(
            ImageZoomState(scale = 1f),
            viewportSize = 1000f to 1000f,
            intrinsicSize = 1000f to 1000f,
            pan = 100f to 100f,
            gestureZoom = 1f
        )
        assertEquals(0f, panned.offsetX, 0.001f)
        assertEquals(0f, panned.offsetY, 0.001f)
    }

    @Test
    fun `a pan stops at the edge of the scaled image`() {
        val panned = imageZoomTransform(
            ImageZoomState(scale = 2f, offsetX = 400f),
            viewportSize = 1000f to 1000f,
            intrinsicSize = 1000f to 1000f,
            pan = 300f to 0f,
            gestureZoom = 1f
        )
        assertEquals(500f, panned.offsetX, 0.001f)
    }

    @Test
    fun `a pinch zooms and clamps the existing pan to the new scale`() {
        val zoomedOut = imageZoomTransform(
            ImageZoomState(scale = 2f, offsetX = 500f, offsetY = 500f),
            viewportSize = 1000f to 1000f,
            intrinsicSize = 1000f to 1000f,
            pan = 0f to 0f,
            gestureZoom = 0.1f
        )
        assertEquals(1f, zoomedOut.scale, 0.001f)
        assertEquals(0f, zoomedOut.offsetX, 0.001f)
        assertEquals(0f, zoomedOut.offsetY, 0.001f)
    }

    @Test
    fun `a letterboxed image pans along its long axis only`() {
        // A 2000x1000 photo fitted into a 1000x1000 viewport is 1000x500 on screen, so at 2x
        // it fills the viewport exactly -- there's nothing above or below it to pan to, but
        // dragging sideways still works.
        val panned = imageZoomTransform(
            ImageZoomState(scale = 2f),
            viewportSize = 1000f to 1000f,
            intrinsicSize = 2000f to 1000f,
            pan = -200f to 300f,
            gestureZoom = 1f
        )
        assertEquals(-200f, panned.offsetX, 0.001f)
        assertEquals(0f, panned.offsetY, 0.001f)
    }

    @Test
    fun `an image that hasn't reported its size yet can still be panned`() {
        // Coil reports intrinsic size only once the image decodes; a pinch-and-drag before
        // then is bounded by the viewport rather than refusing to move.
        val panned = imageZoomTransform(
            ImageZoomState(scale = 2f),
            viewportSize = 1000f to 1000f,
            intrinsicSize = 0f to 0f,
            pan = -300f to 0f,
            gestureZoom = 1f
        )
        assertEquals(-300f, panned.offsetX, 0.001f)
    }

    @Test
    fun `double-tapping zooms in from the fitted size`() {
        val zoomed = imageZoomDoubleTap(ImageZoomState(), 1000f to 1000f, 1000f to 1000f)
        assertTrue(zoomed.scale > 1f)
    }

    @Test
    fun `double-tapping a panned, zoomed image returns it to fitted and centered`() {
        val reset = imageZoomDoubleTap(
            ImageZoomState(scale = 3f, offsetX = 300f, offsetY = -200f),
            1000f to 1000f,
            1000f to 1000f
        )
        assertEquals(1f, reset.scale, 0.001f)
        assertEquals(0f, reset.offsetX, 0.001f)
        assertEquals(0f, reset.offsetY, 0.001f)
    }

    @Test
    fun `an image whose size isn't known yet is assumed to fill the viewport`() {
        // Coil only reports intrinsic size once the image has actually decoded; until then
        // panning has to be bounded by something, and the viewport is the safe guess.
        assertEquals(1000f to 800f, fittedImageSize(1000f, 800f, 0f, 0f))
        assertEquals(1000f to 800f, fittedImageSize(1000f, 800f, Float.NaN, Float.NaN))
    }
}