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 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 = """""" 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 is site-relative, so none of them were zoomable. val html = linkifyImagesForZoom( """x""", baseUrl = "https://ftrain.com/aboard-let-s-have-a-mouse-party" ) assertEquals( """""" + """x""", html ) } @Test fun `a path-relative image source resolves against the article's own directory`() { val html = linkifyImagesForZoom( """""", baseUrl = "https://ex.com/posts/2026/hello" ) assertTrue(html.contains("""""")) } @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 = """""" assertEquals(original, linkifyImagesForZoom(original, baseUrl = "https://ex.com/post")) } @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 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( """

x

""", baseUrl = "https://ftrain.com/aboard-let-s-have-a-mouse-party" ) 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 `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)) } }