merge: W14 — 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」

This commit is contained in:
AskaEth committed 2026-10-04 19:48:52 +08:00
commit f10984622c
10 files changed
+1790 -12

No files matched your search

@@ -0,0 +1,254 @@
package top.yeij.cyrene.data.repository
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.domain.chat.ImageSource
import java.io.File
import java.net.InetSocketAddress
import java.nio.file.Files
import java.util.Base64
/**
* 图片取字节 + 落盘这条路的可测部分:三条来源各自的取法、缓存命中(不重复联网)、
* 不可信名字的回落、失败不留半截文件,以及**真的走一次 HTTP**(用 JDK 自带的
* `com.sun.net.httpserver` 起一个本地端点,验 200 / 404 / 0 字节三种响应)。
*
* 用真临时目录而不是 Android 的 `Context`:这一层的行为全是文件系统与 HTTP 行为,
* 只有把它从 `ContentResolver`(JVM 单测里是 stub,一碰就抛)里摘出来才测得到。
*/
class ImageCacheTest {
private lateinit var cacheRoot: File
@Before
fun setUp() {
cacheRoot = Files.createTempDirectory("cyrene-images").toFile()
}
@After
fun tearDown() {
cacheRoot.deleteRecursively()
}
// —— 服务端来源:命名、命中、回落 ——
@Test
fun `a server image is downloaded once and then served from cache`() = runBlocking {
val downloads = mutableListOf<String>()
val store = store(download = { url -> downloads += url; Result.success(pngBytes()) })
val first = store.load(ImageSource.Server(SERVER_URL)).getOrThrow()
val second = store.load(ImageSource.Server(SERVER_URL)).getOrThrow()
assertEquals(NAME, first.file.name)
assertEquals("同一个存储名的字节永不改变 ⇒ 第二次一个请求都不发", 1, downloads.size)
assertEquals(first.file, second.file)
assertEquals(pngBytes().size.toLong(), second.file.length())
}
@Test
fun `an untrustworthy url name never becomes a path and the image still opens`() = runBlocking {
val downloads = mutableListOf<String>()
val store = store(download = { url -> downloads += url; Result.success(pngBytes()) })
// 编码过的穿越形态:ImageNames 推不出可信名字 ⇒ 按内容寻址落盘(既不该拼路径,也不该拒开)。
val loaded = store.load(ImageSource.Server("http://host/files/%2e%2e%2fsecret.png")).getOrThrow()
assertEquals(1, downloads.size)
assertEquals(32 + 4, loaded.file.name.length)
assertTrue(loaded.file.name.endsWith(".png"))
assertTrue("落盘必须在缓存目录里", loaded.file.parentFile == DocumentCache.directory(cacheRoot))
assertEquals(listOf(loaded.file.name), DocumentCache.directory(cacheRoot).list()!!.toList())
}
@Test
fun `a failed download leaves no half file for the next open to believe`() = runBlocking {
val store = store(
download = { Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork))) },
)
val result = store.load(ImageSource.Server(SERVER_URL))
assertTrue(result.isFailure)
assertFalse(DocumentCache.fileFor(cacheRoot, NAME)!!.exists())
assertFalse("下载失败连缓存目录都不该建", DocumentCache.directory(cacheRoot).exists())
}
@Test
fun `a zero byte leftover is treated as a miss and refetched`() = runBlocking {
val leftover = DocumentCache.fileFor(cacheRoot, NAME)!!
leftover.parentFile!!.mkdirs()
leftover.writeBytes(ByteArray(0))
var downloads = 0
val store = store(download = { downloads++; Result.success(pngBytes()) })
val loaded = store.load(ImageSource.Server(SERVER_URL)).getOrThrow()
assertEquals(1, downloads)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
}
@Test
fun `a cache directory that cannot be written is reported as such`() = runBlocking {
// 拿一个**普通文件**当缓存目录:建目录必然失败。
val blocked = File(cacheRoot, "blocked").apply { writeBytes("not a directory".toByteArray()) }
val store = ImageCacheStore(
cacheDir = blocked,
openLocal = { null },
download = { Result.success(pngBytes()) },
)
val result = store.load(ImageSource.Server(SERVER_URL))
assertTrue(result.exceptionOrNull() is DocumentCacheUnavailableException)
}
// —— 内联来源:不联网 ——
@Test
fun `an inline data uri is decoded locally and never requested`() = runBlocking {
var downloads = 0
val store = store(download = { downloads++; error("内联图片不该发请求") })
val dataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(pngBytes())
val loaded = store.load(ImageSource.Inline(dataUri)).getOrThrow()
assertEquals(0, downloads)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
assertEquals(DocumentCache.localName(pngBytes(), "image.png"), loaded.file.name)
}
@Test
fun `a broken data uri is reported instead of being cached as an empty image`() = runBlocking {
val store = store(download = { error("不该发请求") })
val result = store.load(ImageSource.Inline("data:image/png;base64,%%%%"))
assertTrue(result.exceptionOrNull() is LocalDocumentUnavailableException)
assertFalse(DocumentCache.directory(cacheRoot).exists())
}
// —— 本地来源:拷进缓存 ——
@Test
fun `a local image is copied in content addressed and never requested`() = runBlocking {
val store = store(
download = { error("本地图片不该发请求") },
local = mapOf(LOCAL_URI to pngBytes()),
)
val loaded = store.load(ImageSource.Local(LOCAL_URI)).getOrThrow()
assertEquals(DocumentCache.localName(pngBytes(), "image.png"), loaded.file.name)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
}
@Test
fun `a local image we cannot open is reported as unreadable`() = runBlocking {
val store = store(download = { error("不该走到网络") }, local = emptyMap())
val result = store.load(ImageSource.Local(LOCAL_URI))
assertTrue(result.exceptionOrNull() is LocalDocumentUnavailableException)
}
// —— 真的走一次 HTTP(默认的 download 实现)——
@Test
fun `the default download reads a real response and maps the failures`() {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/files/$NAME") { exchange -> respond(exchange, 200, pngBytes()) }
server.createContext("/files/empty.png") { exchange -> respond(exchange, 200, ByteArray(0)) }
server.createContext("/files/gone.png") { exchange ->
respond(
exchange,
404,
"""{"error":{"code":"not_found","message":"no such file"}}""".toByteArray(),
)
}
server.start()
try {
val base = "http://127.0.0.1:${server.address.port}/files"
val store = ImageCacheStore(cacheDir = cacheRoot, openLocal = { null })
val loaded = runBlocking { store.load(ImageSource.Server("$base/$NAME")) }.getOrThrow()
assertEquals(NAME, loaded.file.name)
assertEquals(pngBytes().size.toLong(), loaded.file.length())
val missing = runBlocking { store.load(ImageSource.Server("$base/gone.png")) }
assertTrue("404 必须是失败", missing.isFailure)
assertTrue(
"服务端有话说就用它那句",
missing.exceptionOrNull()!!.message!!.contains("no such file"),
)
assertFalse(DocumentCache.fileFor(cacheRoot, "gone.png")!!.exists())
val empty = runBlocking { store.load(ImageSource.Server("$base/empty.png")) }
assertTrue("0 字节的响应不是一张图", empty.isFailure)
assertFalse(
"空响应绝不落进缓存(否则下一次会「命中」一份什么都没有的文件)",
DocumentCache.fileFor(cacheRoot, "empty.png")!!.exists(),
)
} finally {
server.stop(0)
}
}
// —— 名字推导的边界(这里只验「不会变成路径」这一半,规则本身在 ImagePreviewTest)——
@Test
fun `an unsafe name is never turned into a cache path`() {
assertNull(DocumentCache.fileFor(cacheRoot, "../escape.png"))
assertNull(DocumentCache.fileFor(cacheRoot, "sub/dir.png"))
assertNull(DocumentCache.fileFor(cacheRoot, "\u0000.png"))
}
// —— 夹具 ——
private fun store(
download: suspend (String) -> Result<ByteArray>,
local: Map<String, ByteArray> = emptyMap(),
) = ImageCacheStore(
cacheDir = cacheRoot,
openLocal = { uri -> local[uri]?.inputStream() },
download = download,
)
private fun respond(exchange: HttpExchange, code: Int, body: ByteArray) {
if (body.isEmpty()) exchange.sendResponseHeaders(code, -1) else {
exchange.sendResponseHeaders(code, body.size.toLong())
}
exchange.responseBody.use { it.write(body) }
}
/** 下载失败只需要一个说得清的类型;生产路径上它是 [CyreneException]。 */
private companion object {
const val HEX = "0123456789abcdef0123456789abcdef"
const val NAME = "$HEX.png"
const val SERVER_URL = "http://host/api/v1/files/$NAME"
const val LOCAL_URI = "content://media/picker/0/1"
fun pngBytes(): ByteArray = byteArrayOf(
0x89.toByte(),
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x42,
0x42,
)
}
}
@@ -0,0 +1,226 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.ImageTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
import java.util.Base64
/**
* 图片全屏查看的纯逻辑:来源判定、缓存名推导(含安全性)、字节嗅探、打开/保存的类型、失败文案。
*
* 这些是**全部**能在 JVM 上判定的部分——解码、手势与 Intent 都得真机/仪器测试,本仓库没有
* 那类测试的基础设施,所以它们不进单测(装配部分在交接里明说没测)。
*/
class ImagePreviewTest {
// —— 来源判定 ——
@Test
fun `an upload url is a server source`() {
val source = ImageSource.of("http://10.0.0.10:10942/api/v1/files/$NAME")
assertTrue(source is ImageSource.Server)
assertEquals("http://10.0.0.10:10942/api/v1/files/$NAME", source?.raw)
assertTrue(ImageSource.of("https://example.test/files/$NAME") is ImageSource.Server)
}
@Test
fun `an inline data uri is its own source`() {
assertTrue(ImageSource.of("data:image/png;base64,AAAA") is ImageSource.Inline)
assertTrue("大小写不该改变判定", ImageSource.of("DATA:image/png;base64,AAAA") is ImageSource.Inline)
}
@Test
fun `everything else is a local reference and a blank one is nothing`() {
assertTrue(ImageSource.of("content://media/picker/0/1") is ImageSource.Local)
assertTrue(ImageSource.of("file:///data/user/0/top.yeij.cyrene/cache/shared/a.png") is ImageSource.Local)
// 形态陌生也按本地读:读不到会如实报「读不到这张图片」,绝不静默什么都不发生。
assertTrue(ImageSource.of("/sdcard/Pictures/a.png") is ImageSource.Local)
assertNull(ImageSource.of(""))
assertNull(ImageSource.of(" "))
}
// —— 缓存名:从服务端地址推 ——
@Test
fun `a server url yields the storage name`() {
assertEquals(NAME, ImageNames.fromServerUrl("http://host:10942/api/v1/files/$NAME"))
assertEquals(NAME, ImageNames.fromServerUrl("https://host/api/v1/files/$NAME"))
assertEquals("缓存名只认路径那一段,查询串与锚点不进文件名", NAME, ImageNames.fromServerUrl("http://host/f/$NAME?v=2#x"))
}
@Test
fun `a name that would climb out of the cache directory is refused`() {
assertNull(ImageNames.fromServerUrl("http://host/files/.."))
assertNull(ImageNames.fromServerUrl("http://host/files/."))
assertNull(ImageNames.fromServerUrl("http://host/files/"))
assertNull(ImageNames.fromServerUrl("http://host"))
// 编码过的穿越:本端从不解释 URL 编码,带 % 的段一律当「不是我们认识的那个名字」。
assertNull(ImageNames.fromServerUrl("http://host/files/%2e%2e%2fsecret.png"))
assertNull(ImageNames.fromServerUrl("http://host/files/a%2Fb.png"))
assertNull(ImageNames.fromServerUrl("http://host/files/a\\b.png"))
assertNull(ImageNames.fromServerUrl("http://host/files/a\u0000b.png"))
// 扩展名不在图片白名单里:推不出类型就不拿它当缓存名(回落内容寻址)。
assertNull(ImageNames.fromServerUrl("http://host/files/$HEX.svg"))
assertNull(ImageNames.fromServerUrl("http://host/files/$HEX"))
assertNull(ImageNames.fromServerUrl("http://host/files/$HEX.png.txt"))
}
@Test
fun `a traversal in the middle of the path cannot affect the last segment`() {
// 最后一段是安全的 ⇒ 回它;危险的是「拿整段路径拼文件名」的做法,本端从不那么做。
assertEquals(NAME, ImageNames.fromServerUrl("http://host/files/../../etc/$NAME"))
}
// —— 字节嗅探 ——
@Test
fun `the whitelisted signatures are recognised`() {
assertEquals(ImageFormats.MIME_PNG, ImageFormats.sniff(pngBytes()))
assertEquals(ImageFormats.MIME_JPEG, ImageFormats.sniff(bytes(0xFF, 0xD8, 0xFF, 0xE0)))
assertEquals(ImageFormats.MIME_GIF, ImageFormats.sniff("GIF89a".toByteArray()))
assertEquals(ImageFormats.MIME_WEBP, ImageFormats.sniff(webpBytes()))
assertEquals(ImageFormats.MIME_BMP, ImageFormats.sniff(bmpBytes()))
}
@Test
fun `anything else is not guessed into an image`() {
assertNull("空字节不是图片", ImageFormats.sniff(ByteArray(0)))
assertNull("HTML 错误页不是图片", ImageFormats.sniff("<html><body>502</body></html>".toByteArray()))
assertNull("文本不是图片", ImageFormats.sniff("AABBCC".toByteArray()))
assertNull("只有 RIFF 头的不是 webp", ImageFormats.sniff("RIFF????".toByteArray()))
assertNull("只有 BM 两个字节不够一个 BMP 头", ImageFormats.sniff("BM".toByteArray()))
}
@Test
fun `mime and extension map both ways`() {
assertEquals(ImageFormats.MIME_PNG, ImageFormats.mimeForExtension("a.png"))
assertEquals(ImageFormats.MIME_JPEG, ImageFormats.mimeForExtension("a.JPG"))
assertEquals(ImageFormats.MIME_JPEG, ImageFormats.mimeForExtension("a.jpeg"))
assertNull(ImageFormats.mimeForExtension("a.heic"))
assertNull(ImageFormats.mimeForExtension("no-extension"))
assertEquals(".png", ImageFormats.extensionFor(ImageFormats.MIME_PNG))
assertEquals(".jpg", ImageFormats.extensionFor(ImageFormats.MIME_JPEG))
assertEquals("认不出的类型不给扩展名", "", ImageFormats.extensionFor("image/heic"))
assertEquals("", ImageFormats.extensionFor(null))
}
// —— 内联 data URI ——
@Test
fun `an inline data uri decodes to the same bytes`() {
val bytes = pngBytes()
val dataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes)
assertEquals(ImageFormats.MIME_PNG, ImageDataUri.declaredMime(dataUri))
assertEquals(bytes.size, ImageDataUri.bytes(dataUri)?.size)
}
@Test
fun `a data uri that is not base64 image data decodes to nothing`() {
assertNull(ImageDataUri.bytes("data:image/png,rawbytes"))
assertNull(ImageDataUri.bytes("content://media/1"))
assertNull(ImageDataUri.bytes("data:image/png;base64,"))
assertNull(ImageDataUri.bytes("data:image/png;base64,%%%%"))
assertNull(ImageDataUri.declaredMime("content://media/1"))
}
// —— 打开 / 保存的类型 ——
@Test
fun `the bytes beat every declared type`() {
assertEquals(
"字节是唯一裁决:名字与声明都只是候选",
ImageFormats.MIME_PNG,
ImageOpenTypes.openType(sniffed = ImageFormats.MIME_PNG, declared = "image/jpeg", name = "a.jpg"),
)
}
@Test
fun `a platform declaration is used when the bytes are unknown`() {
assertEquals(
"本地 HEIC:本端说不清,但平台说得清",
"image/heic",
ImageOpenTypes.openType(sniffed = null, declared = "image/heic", name = HEX),
)
}
@Test
fun `an unhelpful declaration falls through to the extension and then to any image`() {
assertEquals(
"octet-stream 声明对看图没有帮助,退到扩展名",
ImageFormats.MIME_PNG,
ImageOpenTypes.openType(sniffed = null, declared = "application/octet-stream", name = "a.png"),
)
assertEquals(
ImageOpenTypes.ANY_IMAGE,
ImageOpenTypes.openType(sniffed = null, declared = null, name = null),
)
assertEquals(
ImageOpenTypes.ANY_IMAGE,
ImageOpenTypes.openType(sniffed = " ", declared = " ", name = "no-extension"),
)
}
@Test
fun `saving always declares the wildcard type`() {
// ⚠️ 这条不是风格问题:CreateDocument 传具体 mime 时,SAF 的 Downloads provider 会按它给
// 文件名补扩展名(2026-10-05 实机复现 .md → .md.txt)。图片这边同样危险:服务端名字的
// 扩展名与字节嗅探的基类型不保证一致,补一次就成了 x.jpg.png。
assertEquals("*/*", ImageOpenTypes.SAVE_MIME)
}
// —— 失败文案 ——
@Test
fun `every local failure gets its own sentence`() {
val messages = listOf(
imageLoadFailureMessage(LocalDocumentUnavailableException()),
imageLoadFailureMessage(LocalDocumentTooLargeException()),
imageLoadFailureMessage(ImageTooLargeException()),
imageLoadFailureMessage(DocumentCacheUnavailableException()),
)
assertTrue(messages.all { it.isNotBlank() })
assertEquals("读不到 / 太大 / 缓存不可用 三种处境各有各的话", 3, messages.distinct().size)
assertTrue(messages.first().contains("读不到"))
}
@Test
fun `a server failure keeps the servers own words`() {
val notFound = imageLoadFailureMessage(
CyreneException(
CyreneError.Server(code = "document_not_found", message = "document not found", httpStatus = 404),
),
)
val other = imageLoadFailureMessage(
CyreneException(CyreneError.Server(code = "oops", message = "磁盘满了", httpStatus = 500)),
)
assertEquals("服务端上已经没有这张图片了", notFound)
assertTrue("未知码也要原样带出服务端的话", other.contains("磁盘满了"))
}
// —— 夹具 ——
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
private fun pngBytes() = bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00)
private fun webpBytes() =
"RIFF".toByteArray() + bytes(0x10, 0, 0, 0) + "WEBP".toByteArray() + bytes(0, 0)
private fun bmpBytes() = "BM".toByteArray() + ByteArray(12)
private companion object {
const val HEX = "0123456789abcdef0123456789abcdef"
const val NAME = "$HEX.png"
}
}
@@ -0,0 +1,189 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 全屏查看的缩放 / 平移数学。
*
* 这里锚四件事:**缩放钳制**(1x…3x,非有限值也不许漏出去)、**双击的开关语义**、
* **位移钳制**(fit 态拖不动、放大后拖不到露出空白)、以及**焦点不动**(双指捏合/双击时,
* 手指按住的那个点在屏幕上不动——这是「缩放跟手」的全部内容)。
*
* 手势事件本身(`detectTransformGestures` / `detectTapGestures`)不进单测:那要仪器测试,
* 本仓库没有那套基础设施。这里测的是它们**唯一**的落点。
*/
class ImageZoomTest {
/** 竖屏容器 1080×1920,横屏图 4000×2000(fit 后贴住宽度)。 */
private val container = ImageSize(1080f, 1920f)
private val image = ImageSize(4000f, 2000f)
// —— 缩放钳制 ——
@Test
fun `the scale never leaves one to three`() {
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(0.2f), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(0f), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(-4f), TOLERANCE)
assertEquals(ImageZoom.MAX_SCALE, ImageZoom.clampScale(9f), TOLERANCE)
assertEquals(2.5f, ImageZoom.clampScale(2.5f), TOLERANCE)
}
@Test
fun `a non finite scale falls back to fit instead of poisoning the offsets`() {
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(Float.NaN), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.clampScale(Float.POSITIVE_INFINITY), TOLERANCE)
assertEquals(0f, ImageZoom.clampOffset(Float.NaN, 1080f, 1080f, 2f), TOLERANCE)
assertEquals(0f, ImageZoom.maxOffset(Float.NaN, 1080f, 2f), TOLERANCE)
}
// —— 双击的开关语义 ——
@Test
fun `double tap toggles between fit and the double tap scale`() {
assertEquals(ImageZoom.DOUBLE_TAP_SCALE, ImageZoom.doubleTapScale(ImageZoom.MIN_SCALE), TOLERANCE)
assertEquals(ImageZoom.MIN_SCALE, ImageZoom.doubleTapScale(ImageZoom.DOUBLE_TAP_SCALE), TOLERANCE)
assertEquals(
"已经放大到 3x 时双击是收回,不是继续放大",
ImageZoom.MIN_SCALE,
ImageZoom.doubleTapScale(3f),
TOLERANCE,
)
assertFalse(ImageZoom.isZoomed(ImageZoom.MIN_SCALE))
assertTrue(ImageZoom.isZoomed(ImageZoom.DOUBLE_TAP_SCALE))
}
// —— fit-center 的尺寸 ——
@Test
fun `fit center scales the image to touch one side of the container`() {
val fitted = ImageZoom.fittedSize(container, image)
assertEquals("宽的一边贴住容器", 1080f, fitted.width, TOLERANCE)
assertEquals(540f, fitted.height, TOLERANCE)
}
@Test
fun `an unmeasured container or image yields no content size`() {
assertEquals(ImageSize(0f, 0f), ImageZoom.fittedSize(ImageSize(0f, 0f), image))
assertEquals(ImageSize(0f, 0f), ImageZoom.fittedSize(container, ImageSize(0f, 0f)))
}
// —— 位移钳制 ——
@Test
fun `at fit there is nothing to pan`() {
assertEquals(0f, ImageZoom.maxOffset(1080f, 1080f, ImageZoom.MIN_SCALE), TOLERANCE)
val panned = ImageZoom.pan(ZoomPan(), deltaX = 400f, deltaY = 400f, container = container, image = image)
assertEquals(ImageZoom.MIN_SCALE, panned.scale, TOLERANCE)
assertEquals("fit 态下拖不动:内容没超出容器", 0f, panned.offsetX, TOLERANCE)
assertEquals(0f, panned.offsetY, TOLERANCE)
}
@Test
fun `zoomed in the pan stops exactly where the content edge meets the container edge`() {
// 2x 下内容 2160×1080:横向超出 1080 ⇒ 两轴各能移 540;纵向贴满 ⇒ 0。
val limit = ImageZoom.maxOffset(1080f, 1080f, 2f)
assertEquals(540f, limit, TOLERANCE)
val far = ImageZoom.pan(
ZoomPan(scale = 2f),
deltaX = 5_000f,
deltaY = 5_000f,
container = container,
image = image,
)
assertEquals("拖到头就停,绝不把图拖出可视区", 540f, far.offsetX, TOLERANCE)
assertEquals(0f, far.offsetY, TOLERANCE)
}
@Test
fun `a zoomed image can be dragged back and forth inside the limit`() {
val right = ImageZoom.pan(ZoomPan(scale = 2f), 100f, 0f, container, image)
val left = ImageZoom.pan(right, -100f, 0f, container, image)
assertEquals(100f, right.offsetX, TOLERANCE)
assertEquals(0f, left.offsetX, TOLERANCE)
}
// —— 焦点不动 ——
@Test
fun `the point under the fingers stays where it is while zooming`() {
// 方形容器 + 方图:两轴都会超出容器,因此两轴都能验焦点不动(不会有一轴被钳到 0)。
val square = ImageSize(1080f, 1080f)
val squareImage = ImageSize(1000f, 1000f)
val current = ZoomPan(scale = 1f)
val focusX = 300f
val focusY = -200f
// 缩放前,焦点下那个点的内容坐标。
val contentX = (focusX - current.offsetX) / current.scale
val contentY = (focusY - current.offsetY) / current.scale
val zoomed = ImageZoom.zoomAround(current, 2f, focusX, focusY, square, squareImage)
assertEquals("缩放后那个点必须还在同一处", focusX, zoomed.offsetX + contentX * zoomed.scale, TOLERANCE)
assertEquals(focusY, zoomed.offsetY + contentY * zoomed.scale, TOLERANCE)
assertEquals(2f, zoomed.scale, TOLERANCE)
}
@Test
fun `a focus point beyond the pan limit is clamped instead of showing blank space`() {
// 横图在 2x 下纵向不超出容器(540×2 = 1080 < 1920)⇒ 纵轴恒为 0,焦点保持在纵轴上做不到。
val zoomed = ImageZoom.zoomAround(ZoomPan(), 2f, 0f, 300f, container, image)
assertEquals(0f, zoomed.offsetY, TOLERANCE)
}
@Test
fun `zooming past the maximum keeps the maximum`() {
val zoomed = ImageZoom.zoomAround(ZoomPan(scale = 2f), 99f, 0f, 0f, container, image)
assertEquals(ImageZoom.MAX_SCALE, zoomed.scale, TOLERANCE)
}
@Test
fun `zooming back to fit also resets the offsets`() {
val zoomed = ImageZoom.doubleTap(ZoomPan(), 400f, 300f, container, image)
val back = ImageZoom.doubleTap(zoomed, 400f, 300f, container, image)
assertEquals(ImageZoom.MIN_SCALE, back.scale, TOLERANCE)
assertEquals("收回 fit 时位移必须归零,否则图会歪在一边", 0f, back.offsetX, TOLERANCE)
assertEquals(0f, back.offsetY, TOLERANCE)
}
@Test
fun `a pinch at fit that only pans does not invent an offset`() {
val panOnly = ImageZoom.zoomAround(ZoomPan(), 1f, 200f, 200f, container, image)
assertEquals(ImageZoom.MIN_SCALE, panOnly.scale, TOLERANCE)
assertEquals(0f, panOnly.offsetX, TOLERANCE)
assertEquals(0f, panOnly.offsetY, TOLERANCE)
}
@Test
fun `an image taller than the container is clamped on the other axis`() {
// 竖图 2000×4000 在同一个竖屏容器里:fit 后 960×1920(贴高的那一边),
// 2x 后 1920×3840 ⇒ 纵向各能移 (3840-1920)/2 = 960,横向各能移 (1920-1080)/2 = 420。
val portrait = ImageSize(2000f, 4000f)
val fitted = ImageZoom.fittedSize(container, portrait)
val far = ImageZoom.pan(ZoomPan(scale = 2f), 5_000f, 5_000f, container, portrait)
assertEquals(960f, fitted.width, TOLERANCE)
assertEquals(1920f, fitted.height, TOLERANCE)
assertEquals(420f, far.offsetX, TOLERANCE)
assertEquals(960f, far.offsetY, TOLERANCE)
}
private companion object {
const val TOLERANCE = 0.01f
}
}