merge: W14 — 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user