diff options
Diffstat (limited to 'app/src/test/java/org')
| -rw-r--r-- | app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt | 178 |
1 files changed, 178 insertions, 0 deletions
diff --git a/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt b/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt new file mode 100644 index 0000000..81ecc33 --- /dev/null +++ b/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt @@ -0,0 +1,178 @@ +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`() { + // Relative srcs resolve against the WebView's base URL, which a custom-scheme href + // can't reproduce -- 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 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 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 `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)) + } +} |
