merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider)
This commit is contained in:
commit
60b0eacafd
9 files changed
+1625
-10
No files matched your search
@@ -0,0 +1,310 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
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.api.dto.DocumentRefDto
|
||||
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentTooLargeException
|
||||
import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
|
||||
/**
|
||||
* 附件预览的纯逻辑:截断规则、来源选择、打开用的类型、以及「没有 App 能开」那条分支。
|
||||
*
|
||||
* 这里锚的四件事都是**用户能看见的后果**,不是内部实现:
|
||||
* 1. 截断必须**明写**(阈值边界逐条钉住:正好等于上限 / 超一字节 / 超一行);
|
||||
* 2. 「用其它应用打开」的类型**只用服务端嗅探所得的 `mime`**,绝不按扩展名猜;
|
||||
* 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**;
|
||||
* 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名。
|
||||
*/
|
||||
class DocumentPreviewTest {
|
||||
|
||||
private fun ref(
|
||||
name: String = "0123456789abcdef0123456789abcdef.md",
|
||||
mime: String = "text/plain",
|
||||
) = DocumentRefDto(name = name, mime = mime)
|
||||
|
||||
private fun filler(size: Int) = ByteArray(size) { 'a'.code.toByte() }
|
||||
|
||||
// —— 字节上限 ——
|
||||
|
||||
@Test
|
||||
fun `exactly the byte limit is shown in full`() {
|
||||
val slice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES))
|
||||
|
||||
assertFalse(slice.truncated)
|
||||
assertEquals(PreviewTruncation.None, slice.truncatedBy)
|
||||
assertNull("完整显示时一个字都不说", slice.notice)
|
||||
assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one byte over the limit is truncated and said so`() {
|
||||
val slice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES + 1))
|
||||
|
||||
assertTrue(slice.truncated)
|
||||
assertEquals(PreviewTruncation.Bytes, slice.truncatedBy)
|
||||
assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
|
||||
assertEquals(DocumentPreviewLimits.MAX_BYTES + 1, slice.totalBytes)
|
||||
assertTrue(slice.notice!!, slice.notice!!.contains("只显示前 256 KB"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncation always points at the two exits that give the whole file`() {
|
||||
val notice = DocumentPreview.slice(filler(DocumentPreviewLimits.MAX_BYTES + 1)).notice!!
|
||||
|
||||
assertTrue(notice, notice.contains("只显示前"))
|
||||
assertTrue(notice, notice.contains("用其它应用打开"))
|
||||
assertTrue(notice, notice.contains("保存到下载目录"))
|
||||
}
|
||||
|
||||
// —— 行数上限 ——
|
||||
|
||||
@Test
|
||||
fun `exactly the line limit is shown in full`() {
|
||||
val slice = DocumentPreview.slice(lines(DocumentPreviewLimits.MAX_LINES).toByteArray())
|
||||
|
||||
assertFalse(slice.truncated)
|
||||
assertEquals(DocumentPreviewLimits.MAX_LINES, slice.totalLines)
|
||||
assertEquals(DocumentPreviewLimits.MAX_LINES, slice.shownLines)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one line over the limit is truncated and said so`() {
|
||||
val slice = DocumentPreview.slice(lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray())
|
||||
|
||||
assertTrue(slice.truncated)
|
||||
assertEquals(PreviewTruncation.Lines, slice.truncatedBy)
|
||||
assertEquals(DocumentPreviewLimits.MAX_LINES, slice.shownLines)
|
||||
assertEquals(DocumentPreviewLimits.MAX_LINES + 1, slice.totalLines)
|
||||
assertTrue(slice.notice!!, slice.notice!!.contains("只显示前 5,000 行"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when both limits are hit the byte limit is the one that cut first`() {
|
||||
// 6000 行、每行 60 字节 ≈ 366 KB:字节上限先到,所以理由报字节。
|
||||
val text = buildString { repeat(6000) { append("y".repeat(60)); append('\n') } }
|
||||
val slice = DocumentPreview.slice(text.toByteArray())
|
||||
|
||||
assertTrue(slice.truncated)
|
||||
assertEquals(PreviewTruncation.Bytes, slice.truncatedBy)
|
||||
assertEquals(DocumentPreviewLimits.MAX_BYTES, slice.shownBytes)
|
||||
}
|
||||
|
||||
// —— UTF-8 边界 ——
|
||||
|
||||
@Test
|
||||
fun `the byte cut never splits a multibyte character`() {
|
||||
val bytes = "喵".repeat(DocumentPreviewLimits.MAX_BYTES / 3 + 10).toByteArray()
|
||||
|
||||
val slice = DocumentPreview.slice(bytes)
|
||||
|
||||
assertTrue(slice.truncated)
|
||||
assertFalse("切点不该留下替换字符 U+FFFD", slice.text.contains('\uFFFD'))
|
||||
assertEquals(0, slice.text.toByteArray().size % 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the safe prefix steps back over an incomplete character`() {
|
||||
val twoByte = "aé".toByteArray()
|
||||
|
||||
assertEquals(3, DocumentPreview.utf8SafePrefixLength(twoByte, 3))
|
||||
assertEquals(1, DocumentPreview.utf8SafePrefixLength(twoByte, 2))
|
||||
assertEquals(1, DocumentPreview.utf8SafePrefixLength(twoByte, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a four byte character needs all four bytes`() {
|
||||
val emoji = "😀".toByteArray()
|
||||
|
||||
assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 1))
|
||||
assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 2))
|
||||
assertEquals(0, DocumentPreview.utf8SafePrefixLength(emoji, 3))
|
||||
assertEquals(4, DocumentPreview.utf8SafePrefixLength(emoji, 4))
|
||||
}
|
||||
|
||||
// —— 行数 ——
|
||||
|
||||
@Test
|
||||
fun `a trailing newline is not an extra line`() {
|
||||
assertEquals(0, DocumentPreview.lineCount(""))
|
||||
assertEquals(1, DocumentPreview.lineCount("a"))
|
||||
assertEquals(1, DocumentPreview.lineCount("a\n"))
|
||||
assertEquals(2, DocumentPreview.lineCount("a\nb"))
|
||||
assertEquals(2, DocumentPreview.lineCount("a\nb\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the byte level line count agrees with the text one`() {
|
||||
assertEquals(0, DocumentPreview.lineCount(ByteArray(0)))
|
||||
assertEquals(2, DocumentPreview.lineCount("a\nb".toByteArray()))
|
||||
assertEquals(2, DocumentPreview.lineCount("a\nb\n".toByteArray()))
|
||||
}
|
||||
|
||||
// —— 分块 ——
|
||||
|
||||
@Test
|
||||
fun `the body is chunked by lines and joining the chunks returns the text`() {
|
||||
val text = lines(400)
|
||||
|
||||
val chunks = DocumentPreview.chunks(text, DocumentPreviewLimits.LINES_PER_CHUNK)
|
||||
|
||||
assertEquals(2, chunks.size)
|
||||
assertEquals(text, chunks.joinToString(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a partial last chunk is kept`() {
|
||||
assertEquals(3, DocumentPreview.chunks(lines(401), DocumentPreviewLimits.LINES_PER_CHUNK).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole preview becomes twenty five chunks at most`() {
|
||||
// 5000 行上限 ÷ 200 行一块 = 25 块,LazyColumn 因此最多测 25 个 item。
|
||||
assertEquals(
|
||||
DocumentPreviewLimits.MAX_LINES / DocumentPreviewLimits.LINES_PER_CHUNK,
|
||||
DocumentPreview.chunks(lines(DocumentPreviewLimits.MAX_LINES)).size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty body has no chunks`() {
|
||||
assertEquals(emptyList<String>(), DocumentPreview.chunks(""))
|
||||
}
|
||||
|
||||
// —— 打开用的类型:只认嗅探所得的 mime ——
|
||||
|
||||
@Test
|
||||
fun `the open type is the sniffed mime even when the extension says otherwise`() {
|
||||
// 服务端对 data.json 嗅探回 text/plain——按扩展名猜成 application/json 就是说一个
|
||||
// 服务端刻意不承认的类型。
|
||||
val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.json"))
|
||||
|
||||
assertEquals("text/plain", target.openType())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown server type falls back to octet stream instead of guessing`() {
|
||||
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forServer(""))
|
||||
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forServer(" "))
|
||||
assertEquals("text/plain", DocumentOpenTypes.forServer("text/plain"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local file uses the platform declaration and falls back when there is none`() {
|
||||
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forLocal(null))
|
||||
assertEquals(DocumentOpenTypes.OCTET_STREAM, DocumentOpenTypes.forLocal(""))
|
||||
assertEquals("text/csv", DocumentOpenTypes.forLocal("text/csv"))
|
||||
}
|
||||
|
||||
// —— 没有 App 能开 ——
|
||||
|
||||
@Test
|
||||
fun `no app can open it - we say so and point at saving`() {
|
||||
val plan = DocumentOpen.plan("text/plain", hasHandler = false)
|
||||
|
||||
assertTrue(plan is DocumentOpenPlan.NoHandler)
|
||||
val message = (plan as DocumentOpenPlan.NoHandler).message
|
||||
assertTrue(message, message.contains("没有应用能打开"))
|
||||
assertTrue(message, message.contains("保存到下载目录"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a handler means handing the intent off`() {
|
||||
assertEquals(DocumentOpenPlan.HandOff, DocumentOpen.plan("text/plain", hasHandler = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the launch fallback says the very same sentence`() {
|
||||
val plan = DocumentOpen.plan("text/plain", hasHandler = false) as DocumentOpenPlan.NoHandler
|
||||
|
||||
assertEquals(DocumentOpen.noHandlerMessage("text/plain"), plan.message)
|
||||
}
|
||||
|
||||
// —— 来源选择 ——
|
||||
|
||||
@Test
|
||||
fun `a committed reference comes from the server and is named by its storage name`() {
|
||||
val target = DocumentTarget.of(ref(name = "0123456789abcdef0123456789abcdef.md"))
|
||||
|
||||
assertEquals(DocumentSource.Server("0123456789abcdef0123456789abcdef.md", "text/plain"), target.source)
|
||||
// 原始文件名在契约里不可得,保存只能用存储名(不编一个假名字)。
|
||||
assertEquals("0123456789abcdef0123456789abcdef.md", target.suggestedFileName())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft attachment comes from the local uri and keeps its own name`() {
|
||||
val target = DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md"))
|
||||
|
||||
assertEquals(DocumentSource.Local("content://doc/1", "报告.md"), target.source)
|
||||
assertEquals("报告.md", target.suggestedFileName())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft with no name falls back instead of an empty file name`() {
|
||||
val target = DocumentTarget.of(DraftDocument(uri = "content://doc/1", displayName = ""))
|
||||
|
||||
assertEquals("document.txt", target.suggestedFileName())
|
||||
}
|
||||
|
||||
// —— 失败文案:绝不渲染成「空文件」——
|
||||
|
||||
private fun server(code: String, message: String, status: Int) =
|
||||
CyreneException(CyreneError.Server(code, message, status))
|
||||
|
||||
@Test
|
||||
fun `a missing server document says the server no longer has it`() {
|
||||
val message = documentLoadFailureMessage(server("document_not_found", "document not found", 404))
|
||||
|
||||
assertEquals("服务端上已经没有这份文件了", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired session asks for a re-login rather than another file`() {
|
||||
val message = documentLoadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "x")))
|
||||
|
||||
assertTrue(message, message.contains("重新登录"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the three local failures each name the action the user can take`() {
|
||||
assertEquals("读不到这份文件,请重新选择", documentLoadFailureMessage(LocalDocumentUnavailableException()))
|
||||
assertTrue(documentLoadFailureMessage(LocalDocumentTooLargeException()).contains("20 MB"))
|
||||
assertTrue(documentLoadFailureMessage(DocumentCacheUnavailableException()).contains("缓存"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network failure keeps the network reason and names the step`() {
|
||||
val message = documentLoadFailureMessage(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
|
||||
)
|
||||
|
||||
assertEquals("附件加载失败,网络不可用,请检查连接", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no failure ever reads like an empty file`() {
|
||||
val messages = listOf(
|
||||
documentLoadFailureMessage(server("document_not_found", "document not found", 404)),
|
||||
documentLoadFailureMessage(server("weird_code", "boom", 500)),
|
||||
documentLoadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
|
||||
documentLoadFailureMessage(LocalDocumentUnavailableException()),
|
||||
documentLoadFailureMessage(LocalDocumentTooLargeException()),
|
||||
documentLoadFailureMessage(DocumentCacheUnavailableException()),
|
||||
documentLoadFailureMessage(IllegalStateException()),
|
||||
)
|
||||
|
||||
assertTrue(messages.toString(), messages.all { it.isNotBlank() })
|
||||
assertTrue(messages.toString(), messages.none { it.contains("空文件") || it.contains("是空的") })
|
||||
}
|
||||
|
||||
/** 恰好 [count] 行(末行不带换行符)。 */
|
||||
private fun lines(count: Int): String = buildString {
|
||||
repeat(count - 1) { append("x\n") }
|
||||
append('x')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user