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("""

hi

x""") assertEquals( """

hi

x""", 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 = """""" assertEquals(original, linkifyImagesForZoom(original)) } @Test fun `an image after a closed link is still wrapped`() { val html = linkifyImagesForZoom("""t""") assertTrue(html.endsWith("""""")) } @Test fun `a protocol-relative image source becomes an https zoom target`() { val html = linkifyImagesForZoom("""""") assertEquals("""""", 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 = """""" assertEquals(original, linkifyImagesForZoom(original)) } @Test fun `a data uri image is left alone`() { val original = """""" assertEquals(original, linkifyImagesForZoom(original)) } @Test fun `wrapping is not confused by tags that merely start with the letter a`() { val html = linkifyImagesForZoom("""y""") assertTrue(html.contains("""""")) } @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 , 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("""

x

""") assertTrue(html.contains("""
""")) } @Test fun `reader html makes the synthesized hero image zoomable too`() { val html = wrapReaderHtml("

no image of its own

", imageUrl = "https://ex.com/hero.jpg") assertTrue(html.contains("""""")) 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 """) assertTrue(html.contains("""""")) 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)) } }