summaryrefslogtreecommitdiff
path: root/app/src/test/java/org/terst/feedreader
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/test/java/org/terst/feedreader')
-rw-r--r--app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt178
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))
+ }
+}